Compare commits
525 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.nogit/
|
||||||
|
.git/
|
||||||
|
.playwright-mcp/
|
||||||
|
.vscode/
|
||||||
|
test/
|
||||||
|
test_watch/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
|
|
||||||
env:
|
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_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
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_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: registry.gitlab.com/hosttoday/ht-docker-dbase:npmci
|
image: code.foss.global/host.today/ht-docker-node:dbase_dind
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -82,15 +82,13 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @git.zone/tsdocker
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
run: |
|
run: |
|
||||||
npmci docker login
|
tsdocker login
|
||||||
npmci docker build
|
tsdocker build
|
||||||
npmci docker test
|
tsdocker push
|
||||||
# npmci docker push gitea.lossless.digital
|
|
||||||
npmci docker push dockerregistry.lossless.digital
|
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
needs: test
|
needs: test
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ dist_*/
|
|||||||
**/.claude/settings.local.json
|
**/.claude/settings.local.json
|
||||||
.nogit/data/
|
.nogit/data/
|
||||||
readme.plan.md
|
readme.plan.md
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
[ 74ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
|
||||||
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
|
||||||
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
|
||||||
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
|
||||||
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
|
||||||
[ 587ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 697ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[ 669ms] [WARNING] Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information. @ http://localhost:3000/chunk-3L5NJTXF.js:13541
|
|
||||||
[ 729ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
|
|
||||||
[ 27973ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
|
||||||
[ 27973ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
|
||||||
[ 29975ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
|
||||||
[ 29975ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
|
||||||
[ 33977ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
|
||||||
[ 33978ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
|
||||||
[ 41980ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
|
||||||
[ 41980ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
|
||||||
[ 51983ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
|
||||||
[ 51983ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[ 55ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
|
||||||
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
|
||||||
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
|
||||||
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
|
||||||
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
|
||||||
[ 791ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
[ 272ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
|
||||||
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 272ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 274ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Pause-circle
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
|
||||||
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 274ms] [WARNING] Lucide icon 'Pause-circle' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 275ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 275ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
|
||||||
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 297ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 377ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
|
||||||
[ 78064ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 78237ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
|
||||||
[ 127969ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
|
||||||
[ 127969ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
|
||||||
[ 129695ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
|
||||||
[ 129695ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
|
||||||
[ 133309ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
|
||||||
[ 133309ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
|
||||||
[ 141762ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 141910ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[ 437ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
|
||||||
[ 38948ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 52895ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 52896ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 52896ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 52897ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 99401ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 99401ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
[ 75ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
|
||||||
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
|
||||||
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
|
||||||
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
|
||||||
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
|
||||||
[ 763ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
|
||||||
[ 22315ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 22315ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 22316ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 22316ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 22321ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 22322ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 22322ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 22322ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 65371ms] [ERROR] method: >>createApiToken<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 65371ms] [ERROR] Failed to create token: zs @ http://localhost:3000/bundle.js:38142
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[ 642ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
|
||||||
[ 114916ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
|
||||||
[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 179731ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 179732ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 179737ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 179738ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 179738ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 179738ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[ 603ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
[ 308ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 309ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 309ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 310ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 349ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 350ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 350ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 351ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 500ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/apitokens:0
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[ 427ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
|
||||||
[ 44124ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 59106ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 59106ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 59107ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 59107ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 59116ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 59116ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
[ 89192ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
|
||||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
|
||||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
|
||||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
|
||||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
|
||||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
|
||||||
[ 89192ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[ 95ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
|
||||||
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
|
||||||
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
|
||||||
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
|
||||||
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
|
||||||
[ 992ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[ 329ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 727ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
|
||||||
[ 260513ms] [ERROR] method: >>adminLoginWithUsernameAndPassword<< got an ERROR: "login failed" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 260514ms] [ERROR] Login failed: Ns @ http://localhost:3000/bundle.js:38066
|
|
||||||
[ 260518ms] [WARNING] FontAwesome icon not found: circle-xmark @ http://localhost:3000/bundle.js:1203
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[ 397ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
|
||||||
[ 657ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
|
||||||
[ 24180ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -22,7 +22,8 @@
|
|||||||
"to": "./dist_serve/bundle.js",
|
"to": "./dist_serve/bundle.js",
|
||||||
"outputMode": "bundle",
|
"outputMode": "bundle",
|
||||||
"bundler": "esbuild",
|
"bundler": "esbuild",
|
||||||
"production": true
|
"production": true,
|
||||||
|
"includeFiles": ["./html/**/*.html"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -71,9 +72,14 @@
|
|||||||
"dockerRegistryRepoMap": {
|
"dockerRegistryRepoMap": {
|
||||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
|
||||||
},
|
|
||||||
"npmRegistryUrl": "verdaccio.lossless.digital"
|
"npmRegistryUrl": "verdaccio.lossless.digital"
|
||||||
|
},
|
||||||
|
"@git.zone/tsdocker": {
|
||||||
|
"registries": ["code.foss.global"],
|
||||||
|
"registryRepoMap": {
|
||||||
|
"code.foss.global": "serve.zone/dcrouter",
|
||||||
|
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
|
||||||
|
},
|
||||||
|
"platforms": ["linux/amd64", "linux/arm64"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"json.schemas": [
|
"json.schemas": [
|
||||||
{
|
{
|
||||||
"fileMatch": ["/npmextra.json"],
|
"fileMatch": ["/.smartconfig.json"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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.
|
||||||
+21
-33
@@ -1,46 +1,34 @@
|
|||||||
# gitzone dockerfile_service
|
# gitzone dockerfile_service
|
||||||
## STAGE 1 // BUILD
|
## STAGE 1 // BUILD
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1
|
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||||
COPY ./ /app
|
COPY ./ /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
RUN pnpm config set store-dir .pnpm-store
|
||||||
RUN rm -rf node_modules && pnpm install
|
RUN rm -rf node_modules && pnpm install
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
|
||||||
|
|
||||||
|
## STAGE 2 // PRODUCTION
|
||||||
|
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
|
||||||
|
|
||||||
|
# gcompat + libstdc++ for glibc-linked Rust binaries (smartproxy, smartmta, remoteingress)
|
||||||
|
RUN apk add --no-cache gcompat libstdc++
|
||||||
|
|
||||||
# gitzone dockerfile_service
|
|
||||||
## STAGE 2 // install production
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node2
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=node1 /app /app
|
COPY --from=build /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
|
|
||||||
|
|
||||||
|
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||||
|
ENV DCROUTER_HEAP_SIZE=512
|
||||||
|
ENV UV_THREADPOOL_SIZE=16
|
||||||
|
|
||||||
## STAGE 3 // rebuild dependencies for alpine
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpinenpmci as node3
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=node2 /app /app
|
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
|
||||||
RUN pnpm rebuild -r
|
|
||||||
|
|
||||||
## STAGE 4 // the final production image with all dependencies in place
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpine as node4
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=node3 /app /app
|
|
||||||
|
|
||||||
### Healthchecks
|
|
||||||
RUN pnpm install -g @servezone/healthy
|
RUN pnpm install -g @servezone/healthy
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
|
||||||
|
|
||||||
EXPOSE 80
|
LABEL org.opencontainers.image.title="dcrouter" \
|
||||||
CMD ["npm", "start"]
|
org.opencontainers.image.description="Multi-service datacenter gateway" \
|
||||||
|
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
|
||||||
|
|
||||||
|
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
|
||||||
|
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]
|
||||||
|
|||||||
+1619
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
+42
-31
@@ -1,65 +1,74 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "10.1.8",
|
"version": "13.25.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist_ts/index.js",
|
".": "./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",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --logfile --timeout 60)",
|
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
"start": "(node ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
|
"build:docker": "tsdocker build --verbose",
|
||||||
|
"release:docker": "tsdocker push --verbose",
|
||||||
"bundle": "(tsbundle)",
|
"bundle": "(tsbundle)",
|
||||||
"watch": "tswatch"
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.1.2",
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
"@git.zone/tsbundle": "^2.9.0",
|
"@git.zone/tsbundle": "^2.10.0",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^3.1.8",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@git.zone/tswatch": "^3.2.0",
|
"@git.zone/tswatch": "^3.3.2",
|
||||||
"@types/node": "^25.3.3"
|
"@types/node": "^25.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.2.7",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedserver": "^8.4.0",
|
"@api.global/typedserver": "^8.4.6",
|
||||||
"@api.global/typedsocket": "^4.1.2",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@design.estate/dees-catalog": "^3.43.3",
|
"@design.estate/dees-catalog": "^3.78.2",
|
||||||
"@design.estate/dees-element": "^2.1.6",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/projectinfo": "^5.0.2",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartacme": "^9.1.3",
|
"@push.rocks/smartacme": "^9.5.0",
|
||||||
"@push.rocks/smartdata": "^7.1.0",
|
"@push.rocks/smartdata": "^7.1.7",
|
||||||
|
"@push.rocks/smartdb": "^2.6.2",
|
||||||
"@push.rocks/smartdns": "^7.9.0",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
"@push.rocks/smartfile": "^13.1.2",
|
"@push.rocks/smartfs": "^1.5.0",
|
||||||
"@push.rocks/smartguard": "^3.1.0",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
"@push.rocks/smartjwt": "^2.2.1",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartlog": "^3.2.1",
|
"@push.rocks/smartlog": "^3.2.2",
|
||||||
"@push.rocks/smartmetrics": "^3.0.2",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
"@push.rocks/smartmongo": "^5.1.0",
|
"@push.rocks/smartmigration": "1.2.0",
|
||||||
"@push.rocks/smartmta": "^5.3.1",
|
"@push.rocks/smartmta": "^5.3.3",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@push.rocks/smartnetwork": "^4.7.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^25.9.0",
|
"@push.rocks/smartproxy": "^27.9.0",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.2.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@serve.zone/catalog": "^2.5.0",
|
"@push.rocks/smartvpn": "1.19.2",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/remoteingress": "^4.4.0",
|
"@serve.zone/catalog": "^2.12.4",
|
||||||
"@tsclass/tsclass": "^9.3.0",
|
"@serve.zone/interfaces": "^5.4.3",
|
||||||
"lru-cache": "^11.2.6",
|
"@serve.zone/remoteingress": "^4.17.1",
|
||||||
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"lru-cache": "^11.3.5",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -99,13 +108,15 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
"ts_web/**/*",
|
"ts_web/**/*",
|
||||||
|
"ts_apiclient/**/*",
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist_*/**/*",
|
"dist_*/**/*",
|
||||||
"dist_ts/**/*",
|
"dist_ts/**/*",
|
||||||
"dist_ts_web/**/*",
|
"dist_ts_web/**/*",
|
||||||
|
"dist_ts_apiclient/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2894
-3037
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -133,7 +133,7 @@ The project now uses tswatch for development:
|
|||||||
```bash
|
```bash
|
||||||
pnpm run watch
|
pnpm run watch
|
||||||
```
|
```
|
||||||
Configuration in `npmextra.json`:
|
Configuration in `.smartconfig.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@git.zone/tswatch": {
|
"@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,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();
|
||||||
@@ -129,7 +129,8 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
tls: {
|
tls: {
|
||||||
contactEmail: 'test@example.com'
|
contactEmail: 'test@example.com'
|
||||||
},
|
},
|
||||||
cacheConfig: {
|
opsServerPort: 3104,
|
||||||
|
dbConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -142,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
|
|
||||||
// Verify unified email server was initialized
|
// Verify unified email server was initialized
|
||||||
expect(router.emailServer).toBeTruthy();
|
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
|
// Stop the router
|
||||||
await router.stop();
|
await router.stop();
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
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, DomainDoc, RouteDoc } from '../ts/db/index.js';
|
||||||
|
import { DnsManager } from '../ts/dns/manager.dns.js';
|
||||||
|
import { logger } from '../ts/logger.js';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
const createTestDb = async () => {
|
||||||
|
const storagePath = plugins.path.join(
|
||||||
|
plugins.os.tmpdir(),
|
||||||
|
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
const db = DcRouterDb.getInstance({
|
||||||
|
storagePath,
|
||||||
|
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
});
|
||||||
|
await db.start();
|
||||||
|
await db.getDb().mongoDb.createCollection('__test_init');
|
||||||
|
|
||||||
|
return {
|
||||||
|
async cleanup() {
|
||||||
|
await db.stop();
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const testDbPromise = createTestDb();
|
||||||
|
|
||||||
|
const clearTestState = async () => {
|
||||||
|
for (const route of await RouteDoc.findAll()) {
|
||||||
|
await route.delete();
|
||||||
|
}
|
||||||
|
for (const domain of await DomainDoc.findAll()) {
|
||||||
|
await domain.delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const dcRouter = new DcRouter({
|
||||||
|
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||||
|
dnsScopes: ['example.com'],
|
||||||
|
smartProxyConfig: { routes: [] },
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const appliedRoutes: any[][] = [];
|
||||||
|
const smartProxy = {
|
||||||
|
updateRoutes: async (routes: any[]) => {
|
||||||
|
appliedRoutes.push(routes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeManager = new RouteConfigManager(
|
||||||
|
() => smartProxy as any,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
||||||
|
);
|
||||||
|
|
||||||
|
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
||||||
|
|
||||||
|
const persistedRoutes = await RouteDoc.findAll();
|
||||||
|
expect(persistedRoutes.length).toEqual(2);
|
||||||
|
expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
|
||||||
|
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
|
||||||
|
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
|
||||||
|
|
||||||
|
const mergedRoutes = routeManager.getMergedRoutes().routes;
|
||||||
|
expect(mergedRoutes.length).toEqual(2);
|
||||||
|
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
|
||||||
|
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
|
||||||
|
|
||||||
|
expect(appliedRoutes.length).toEqual(1);
|
||||||
|
|
||||||
|
for (const routeSet of appliedRoutes) {
|
||||||
|
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
|
||||||
|
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
|
||||||
|
|
||||||
|
expect(dnsQueryRoute).toBeDefined();
|
||||||
|
expect(resolveRoute).toBeDefined();
|
||||||
|
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
|
||||||
|
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const dcRouter = new DcRouter({
|
||||||
|
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||||
|
dnsScopes: ['example.com'],
|
||||||
|
smartProxyConfig: { routes: [] },
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const staleDnsQueryRoute = new RouteDoc();
|
||||||
|
staleDnsQueryRoute.id = 'stale-doh-query';
|
||||||
|
staleDnsQueryRoute.route = {
|
||||||
|
name: 'dns-over-https-dns-query',
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
domains: ['ns1.example.com'],
|
||||||
|
path: '/dns-query',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'socket-handler' as any,
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
staleDnsQueryRoute.enabled = true;
|
||||||
|
staleDnsQueryRoute.createdAt = Date.now();
|
||||||
|
staleDnsQueryRoute.updatedAt = Date.now();
|
||||||
|
staleDnsQueryRoute.createdBy = 'test';
|
||||||
|
staleDnsQueryRoute.origin = 'dns';
|
||||||
|
await staleDnsQueryRoute.save();
|
||||||
|
|
||||||
|
const appliedRoutes: any[][] = [];
|
||||||
|
const smartProxy = {
|
||||||
|
updateRoutes: async (routes: any[]) => {
|
||||||
|
appliedRoutes.push(routes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeManager = new RouteConfigManager(
|
||||||
|
() => smartProxy as any,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
||||||
|
);
|
||||||
|
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
||||||
|
|
||||||
|
const remainingRoutes = await RouteDoc.findAll();
|
||||||
|
expect(remainingRoutes.length).toEqual(2);
|
||||||
|
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
|
||||||
|
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
|
||||||
|
|
||||||
|
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
|
||||||
|
expect(queryRoute?.id).toEqual('stale-doh-query');
|
||||||
|
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
|
||||||
|
|
||||||
|
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
|
||||||
|
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
|
||||||
|
|
||||||
|
expect(appliedRoutes.length).toEqual(1);
|
||||||
|
expect(appliedRoutes[0].length).toEqual(2);
|
||||||
|
expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RouteConfigManager only allows toggling system routes', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const smartProxy = {
|
||||||
|
updateRoutes: async (_routes: any[]) => {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeManager = new RouteConfigManager(() => smartProxy as any);
|
||||||
|
await routeManager.initialize([
|
||||||
|
{
|
||||||
|
name: 'system-config-route',
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
domains: ['app.example.com'],
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||||
|
tls: { mode: 'terminate' as const },
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
], [], []);
|
||||||
|
|
||||||
|
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
|
||||||
|
expect(systemRoute).toBeDefined();
|
||||||
|
|
||||||
|
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
|
||||||
|
route: { name: 'renamed-system-route' } as any,
|
||||||
|
});
|
||||||
|
expect(updateResult.success).toEqual(false);
|
||||||
|
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
|
||||||
|
|
||||||
|
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
|
||||||
|
expect(deleteResult.success).toEqual(false);
|
||||||
|
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
|
||||||
|
|
||||||
|
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
|
||||||
|
expect(toggleResult.success).toEqual(true);
|
||||||
|
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('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: {
|
smartProxyConfig: {
|
||||||
routes: []
|
routes: []
|
||||||
},
|
},
|
||||||
cacheConfig: { enabled: false }
|
opsServerPort: 3100,
|
||||||
|
dbConfig: { enabled: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
await dcRouter.start();
|
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,173 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
const TEST_PORT = 3201;
|
||||||
|
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
let removedQueueItemId: string | undefined;
|
||||||
|
let lastEnqueueArgs: any[] | undefined;
|
||||||
|
|
||||||
|
const queueItems = [
|
||||||
|
{
|
||||||
|
id: 'failed-email-1',
|
||||||
|
status: 'failed',
|
||||||
|
attempts: 3,
|
||||||
|
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
|
||||||
|
lastError: '550 mailbox unavailable',
|
||||||
|
processingMode: 'mta',
|
||||||
|
route: undefined,
|
||||||
|
createdAt: new Date('2026-04-14T09:00:00.000Z'),
|
||||||
|
processingResult: {
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: ['recipient@example.net'],
|
||||||
|
cc: ['copy@example.net'],
|
||||||
|
subject: 'Older message',
|
||||||
|
text: 'hello',
|
||||||
|
headers: { 'x-test': '1' },
|
||||||
|
getMessageId: () => 'message-older',
|
||||||
|
getAttachmentsSize: () => 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delivered-email-1',
|
||||||
|
status: 'delivered',
|
||||||
|
attempts: 1,
|
||||||
|
processingMode: 'mta',
|
||||||
|
route: undefined,
|
||||||
|
createdAt: new Date('2026-04-14T11:00:00.000Z'),
|
||||||
|
processingResult: {
|
||||||
|
email: {
|
||||||
|
from: 'fresh@example.com',
|
||||||
|
to: ['new@example.net'],
|
||||||
|
cc: [],
|
||||||
|
subject: 'Newest message',
|
||||||
|
},
|
||||||
|
html: '<p>newest</p>',
|
||||||
|
text: 'newest',
|
||||||
|
headers: { 'x-fresh': 'true' },
|
||||||
|
getMessageId: () => 'message-newer',
|
||||||
|
getAttachmentsSize: () => 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
opsServerPort: TEST_PORT,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
testDcRouter.emailServer = {
|
||||||
|
getQueueItems: () => [...queueItems],
|
||||||
|
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
|
||||||
|
getQueueStats: () => ({
|
||||||
|
queueSize: 2,
|
||||||
|
status: {
|
||||||
|
pending: 0,
|
||||||
|
processing: 1,
|
||||||
|
failed: 1,
|
||||||
|
deferred: 1,
|
||||||
|
delivered: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
deliveryQueue: {
|
||||||
|
enqueue: async (...args: any[]) => {
|
||||||
|
lastEnqueueArgs = args;
|
||||||
|
return 'resent-queue-id';
|
||||||
|
},
|
||||||
|
removeItem: async (id: string) => {
|
||||||
|
removedQueueItemId = id;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin for email API tests', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
BASE_URL,
|
||||||
|
'adminLoginWithUsernameAndPassword',
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
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' });
|
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(PlatformError);
|
expect(error).toBeInstanceOf(PlatformError);
|
||||||
|
if (!(error instanceof PlatformError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||||
expect(error.context.operation).toEqual('testExecution');
|
expect(error.context.operation).toEqual('testExecution');
|
||||||
}
|
}
|
||||||
@@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
expect(error.message).toEqual('Critical error');
|
expect(error.message).toEqual('Critical error');
|
||||||
expect(attempts).toEqual(1); // Should only attempt once
|
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
|
// Should not reach here
|
||||||
expect(false).toEqual(true);
|
expect(false).toEqual(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
expect(error.message).toContain('Flaky failure');
|
expect(error.message).toContain('Flaky failure');
|
||||||
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
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();
|
||||||
+33
-20
@@ -9,7 +9,8 @@ let identity: interfaces.data.IIdentity;
|
|||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
cacheConfig: { enabled: false },
|
opsServerPort: 3102,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
|
|
||||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -26,22 +27,26 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
|||||||
username: 'admin',
|
username: 'admin',
|
||||||
password: 'admin'
|
password: 'admin'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('identity');
|
expect(response).toHaveProperty('identity');
|
||||||
expect(response.identity).toHaveProperty('jwt');
|
const responseIdentity = response.identity;
|
||||||
expect(response.identity).toHaveProperty('userId');
|
if (!responseIdentity) {
|
||||||
expect(response.identity).toHaveProperty('name');
|
throw new Error('Expected admin login response to include identity');
|
||||||
expect(response.identity).toHaveProperty('expiresAt');
|
}
|
||||||
expect(response.identity).toHaveProperty('role');
|
expect(responseIdentity).toHaveProperty('jwt');
|
||||||
expect(response.identity.role).toEqual('admin');
|
expect(responseIdentity).toHaveProperty('userId');
|
||||||
|
expect(responseIdentity).toHaveProperty('name');
|
||||||
identity = response.identity;
|
expect(responseIdentity).toHaveProperty('expiresAt');
|
||||||
|
expect(responseIdentity).toHaveProperty('role');
|
||||||
|
expect(responseIdentity.role).toEqual('admin');
|
||||||
|
|
||||||
|
identity = responseIdentity;
|
||||||
console.log('JWT:', identity.jwt);
|
console.log('JWT:', identity.jwt);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should verify valid JWT identity', async () => {
|
tap.test('should verify valid JWT identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -52,12 +57,16 @@ tap.test('should verify valid JWT identity', async () => {
|
|||||||
expect(response).toHaveProperty('valid');
|
expect(response).toHaveProperty('valid');
|
||||||
expect(response.valid).toBeTrue();
|
expect(response.valid).toBeTrue();
|
||||||
expect(response).toHaveProperty('identity');
|
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 () => {
|
tap.test('should reject invalid JWT', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,7 +83,7 @@ tap.test('should reject invalid JWT', async () => {
|
|||||||
|
|
||||||
tap.test('should verify JWT matches identity data', async () => {
|
tap.test('should verify JWT matches identity data', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -85,13 +94,17 @@ tap.test('should verify JWT matches identity data', async () => {
|
|||||||
|
|
||||||
expect(response).toHaveProperty('valid');
|
expect(response).toHaveProperty('valid');
|
||||||
expect(response.valid).toBeTrue();
|
expect(response.valid).toBeTrue();
|
||||||
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
const responseIdentity = response.identity;
|
||||||
expect(response.identity.userId).toEqual(identity.userId);
|
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 () => {
|
tap.test('should handle logout', async () => {
|
||||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLogout'
|
'adminLogout'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +118,7 @@ tap.test('should handle logout', async () => {
|
|||||||
|
|
||||||
tap.test('should reject wrong credentials', async () => {
|
tap.test('should reject wrong credentials', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -128,4 +141,4 @@ tap.test('should stop DCRouter', async () => {
|
|||||||
await testDcRouter.stop();
|
await testDcRouter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
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;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
connections: {
|
||||||
|
active: () => 0,
|
||||||
|
total: () => 0,
|
||||||
|
byRoute: () => args.connectionsByRoute,
|
||||||
|
byIP: () => new Map<string, number>(),
|
||||||
|
topIPs: () => [],
|
||||||
|
domainRequestsByIP: () => args.domainRequestsByIP,
|
||||||
|
topDomainRequests: () => [],
|
||||||
|
frontendProtocols: () => emptyProtocolDistribution,
|
||||||
|
backendProtocols: () => emptyProtocolDistribution,
|
||||||
|
},
|
||||||
|
throughput: {
|
||||||
|
instant: () => ({ in: 0, out: 0 }),
|
||||||
|
recent: () => ({ in: 0, out: 0 }),
|
||||||
|
average: () => ({ in: 0, out: 0 }),
|
||||||
|
custom: () => ({ in: 0, out: 0 }),
|
||||||
|
history: () => [],
|
||||||
|
byRoute: () => args.throughputByRoute,
|
||||||
|
byIP: () => new Map<string, { in: number; out: number }>(),
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
perSecond: () => 0,
|
||||||
|
perMinute: () => 0,
|
||||||
|
total: () => args.requestsTotal || 0,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
+59
-19
@@ -4,27 +4,49 @@ import { TypedRequest } from '@api.global/typedrequest';
|
|||||||
import * as interfaces from '../ts_interfaces/index.js';
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
let testDcRouter: DcRouter;
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
cacheConfig: { enabled: false },
|
opsServerPort: 3101,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'http://localhost:3101/typedrequest',
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
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 () => {
|
tap.test('should respond to health status request', async () => {
|
||||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getHealthStatus'
|
'getHealthStatus'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await healthRequest.fire({
|
const response = await healthRequest.fire({
|
||||||
detailed: false
|
identity: adminIdentity,
|
||||||
|
detailed: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('health');
|
expect(response).toHaveProperty('health');
|
||||||
expect(response.health.healthy).toBeTrue();
|
expect(response.health.healthy).toBeTrue();
|
||||||
expect(response.health.services).toHaveProperty('OpsServer');
|
expect(response.health.services).toHaveProperty('OpsServer');
|
||||||
@@ -32,14 +54,15 @@ tap.test('should respond to health status request', async () => {
|
|||||||
|
|
||||||
tap.test('should respond to server statistics request', async () => {
|
tap.test('should respond to server statistics request', async () => {
|
||||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getServerStatistics'
|
'getServerStatistics'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await statsRequest.fire({
|
const response = await statsRequest.fire({
|
||||||
includeHistory: false
|
identity: adminIdentity,
|
||||||
|
includeHistory: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('stats');
|
expect(response).toHaveProperty('stats');
|
||||||
expect(response.stats).toHaveProperty('uptime');
|
expect(response.stats).toHaveProperty('uptime');
|
||||||
expect(response.stats).toHaveProperty('cpuUsage');
|
expect(response.stats).toHaveProperty('cpuUsage');
|
||||||
@@ -48,12 +71,14 @@ tap.test('should respond to server statistics request', async () => {
|
|||||||
|
|
||||||
tap.test('should respond to configuration request', async () => {
|
tap.test('should respond to configuration request', async () => {
|
||||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getConfiguration'
|
'getConfiguration'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await configRequest.fire({});
|
const response = await configRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('config');
|
expect(response).toHaveProperty('config');
|
||||||
expect(response.config).toHaveProperty('system');
|
expect(response.config).toHaveProperty('system');
|
||||||
expect(response.config).toHaveProperty('smartProxy');
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
@@ -67,22 +92,37 @@ tap.test('should respond to configuration request', async () => {
|
|||||||
|
|
||||||
tap.test('should handle log retrieval request', async () => {
|
tap.test('should handle log retrieval request', async () => {
|
||||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getRecentLogs'
|
'getRecentLogs'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await logsRequest.fire({
|
const response = await logsRequest.fire({
|
||||||
limit: 10
|
identity: adminIdentity,
|
||||||
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('logs');
|
expect(response).toHaveProperty('logs');
|
||||||
expect(response).toHaveProperty('total');
|
expect(response).toHaveProperty('total');
|
||||||
expect(response).toHaveProperty('hasMore');
|
expect(response).toHaveProperty('hasMore');
|
||||||
expect(response.logs).toBeArray();
|
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 () => {
|
tap.test('should stop DCRouter', async () => {
|
||||||
await testDcRouter.stop();
|
await testDcRouter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ let adminIdentity: interfaces.data.IIdentity;
|
|||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
cacheConfig: { enabled: false },
|
opsServerPort: 3103,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
|
|
||||||
tap.test('should login as admin', async () => {
|
tap.test('should login as admin', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -28,13 +29,17 @@ tap.test('should login as admin', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('identity');
|
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');
|
console.log('Admin logged in with JWT');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should allow admin to verify identity', async () => {
|
tap.test('should allow admin to verify identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,7 +54,7 @@ tap.test('should allow admin to verify identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject verify identity without identity', async () => {
|
tap.test('should reject verify identity without identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -64,7 +69,7 @@ tap.test('should reject verify identity without identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,28 +87,31 @@ 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>(
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'getHealthStatus'
|
'getHealthStatus'
|
||||||
);
|
);
|
||||||
|
|
||||||
// No identity provided
|
try {
|
||||||
const response = await healthRequest.fire({});
|
// No identity provided — should be rejected
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
expect(response).toHaveProperty('health');
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
expect(response.health.healthy).toBeTrue();
|
} catch (error) {
|
||||||
console.log('Public endpoint accessible without auth');
|
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>(
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'getConfiguration'
|
'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).toHaveProperty('config');
|
||||||
expect(response.config).toHaveProperty('system');
|
expect(response.config).toHaveProperty('system');
|
||||||
@@ -114,7 +122,7 @@ tap.test('should allow read-only config access', async () => {
|
|||||||
expect(response.config).toHaveProperty('cache');
|
expect(response.config).toHaveProperty('cache');
|
||||||
expect(response.config).toHaveProperty('radius');
|
expect(response.config).toHaveProperty('radius');
|
||||||
expect(response.config).toHaveProperty('remoteIngress');
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
console.log('Configuration read successfully');
|
console.log('Authenticated access to config successful');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should stop DCRouter', async () => {
|
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,129 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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('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,212 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
const TEST_PORT = 3200;
|
||||||
|
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Setup — db disabled, handlers return graceful fallbacks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
opsServerPort: TEST_PORT,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
TEST_URL,
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
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,110 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||||
|
import { VpnManager } from '../ts/vpn/classes.vpn-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 = {
|
||||||
|
setVpnClientIpsResolver: (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();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start()
|
||||||
+25
-2
@@ -1,6 +1,8 @@
|
|||||||
import { DcRouter } from '../ts/index.js';
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
const devRouter = new DcRouter({
|
const devRouter = new DcRouter({
|
||||||
|
// Server public IP (used for VPN AllowedIPs)
|
||||||
|
publicIp: '203.0.113.1',
|
||||||
// SmartProxy routes for development/demo
|
// SmartProxy routes for development/demo
|
||||||
smartProxyConfig: {
|
smartProxyConfig: {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -23,10 +25,31 @@ const devRouter = new DcRouter({
|
|||||||
tls: { mode: 'passthrough' },
|
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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Disable cache/mongo for dev
|
dbConfig: { enabled: true },
|
||||||
cacheConfig: { enabled: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Starting DcRouter in development mode...');
|
console.log('Starting DcRouter in development mode...');
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '10.1.8',
|
version: '13.25.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
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 { logger } from './logger.js';
|
||||||
import type { StorageManager } from './storage/index.js';
|
import { CertBackoffDoc } from './db/index.js';
|
||||||
|
|
||||||
interface IBackoffEntry {
|
interface IBackoffEntry {
|
||||||
failures: number;
|
failures: number;
|
||||||
@@ -10,65 +10,86 @@ interface IBackoffEntry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages certificate provisioning scheduling with:
|
* Manages certificate provisioning scheduling with:
|
||||||
* - Per-domain exponential backoff persisted in StorageManager
|
* - Per-domain exponential backoff persisted via CertBackoffDoc
|
||||||
*
|
*
|
||||||
* Note: Serial stagger queue was removed — smartacme v9 handles
|
* Note: Serial stagger queue was removed — smartacme v9 handles
|
||||||
* concurrency, per-domain dedup, and rate limiting internally.
|
* concurrency, per-domain dedup, and rate limiting internally.
|
||||||
*/
|
*/
|
||||||
export class CertProvisionScheduler {
|
export class CertProvisionScheduler {
|
||||||
private storageManager: StorageManager;
|
|
||||||
private maxBackoffHours: number;
|
private maxBackoffHours: number;
|
||||||
|
|
||||||
// In-memory backoff cache (mirrors storage for fast lookups)
|
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||||
private backoffCache = new Map<string, IBackoffEntry>();
|
private backoffCache = new Map<string, IBackoffEntry>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
storageManager: StorageManager,
|
|
||||||
options?: { maxBackoffHours?: number }
|
options?: { maxBackoffHours?: number }
|
||||||
) {
|
) {
|
||||||
this.storageManager = storageManager;
|
|
||||||
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage key for a domain's backoff entry
|
* Sanitized domain key for storage lookups
|
||||||
*/
|
*/
|
||||||
private backoffKey(domain: string): string {
|
private sanitizeDomain(domain: string): string {
|
||||||
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
return `/cert-backoff/${clean}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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> {
|
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
||||||
const cached = this.backoffCache.get(domain);
|
const cached = this.backoffCache.get(domain);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
const sanitized = this.sanitizeDomain(domain);
|
||||||
if (entry) {
|
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);
|
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> {
|
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||||
this.backoffCache.set(domain, entry);
|
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> {
|
async isInBackoff(domain: string): Promise<boolean> {
|
||||||
const entry = await this.loadBackoff(domain);
|
const entry = await this.loadBackoff(domain);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
|
|
||||||
const retryAfter = new Date(entry.retryAfter);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,9 +121,13 @@ export class CertProvisionScheduler {
|
|||||||
async clearBackoff(domain: string): Promise<void> {
|
async clearBackoff(domain: string): Promise<void> {
|
||||||
this.backoffCache.delete(domain);
|
this.backoffCache.delete(domain);
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// Ignore delete errors (key may not exist)
|
// Ignore delete errors (doc may not exist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +149,12 @@ export class CertProvisionScheduler {
|
|||||||
const entry = await this.loadBackoff(domain);
|
const entry = await this.loadBackoff(domain);
|
||||||
if (!entry) return null;
|
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);
|
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 {
|
return {
|
||||||
failures: entry.failures,
|
failures: entry.failures,
|
||||||
|
|||||||
+1241
-476
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,58 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { StorageManager } from './storage/index.js';
|
import { AcmeCertDoc } from './db/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ICertManager implementation backed by StorageManager.
|
* ICertManager implementation backed by smartdata document classes.
|
||||||
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
* Persists SmartAcme certificates via AcmeCertDoc so they
|
||||||
* survive process restarts without re-hitting ACME.
|
* survive process restarts without re-hitting ACME.
|
||||||
*/
|
*/
|
||||||
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||||
private keyPrefix = '/certs/';
|
constructor() {}
|
||||||
|
|
||||||
constructor(private storageManager: StorageManager) {}
|
|
||||||
|
|
||||||
async init(): Promise<void> {}
|
async init(): Promise<void> {}
|
||||||
|
|
||||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||||
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||||
if (!data) return null;
|
if (!doc) return null;
|
||||||
return new plugins.smartacme.Cert(data);
|
return new plugins.smartacme.Cert({
|
||||||
}
|
id: doc.id,
|
||||||
|
domainName: doc.domainName,
|
||||||
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
created: doc.created,
|
||||||
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
privateKey: doc.privateKey,
|
||||||
id: cert.id,
|
publicKey: doc.publicKey,
|
||||||
domainName: cert.domainName,
|
csr: doc.csr,
|
||||||
created: cert.created,
|
validUntil: doc.validUntil,
|
||||||
privateKey: cert.privateKey,
|
|
||||||
publicKey: cert.publicKey,
|
|
||||||
csr: cert.csr,
|
|
||||||
validUntil: cert.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> {
|
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 close(): Promise<void> {}
|
||||||
|
|
||||||
async wipe(): Promise<void> {
|
async wipe(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(this.keyPrefix);
|
const docs = await AcmeCertDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
await this.storageManager.delete(key);
|
await doc.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
import { ApiTokenDoc } from '../db/index.js';
|
||||||
import type {
|
import type {
|
||||||
IStoredApiToken,
|
IStoredApiToken,
|
||||||
IApiTokenInfo,
|
IApiTokenInfo,
|
||||||
TApiTokenScope,
|
TApiTokenScope,
|
||||||
} from '../../ts_interfaces/data/route-management.js';
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
const TOKENS_PREFIX = '/config-api/tokens/';
|
|
||||||
const TOKEN_PREFIX_STR = 'dcr_';
|
const TOKEN_PREFIX_STR = 'dcr_';
|
||||||
|
|
||||||
export class ApiTokenManager {
|
export class ApiTokenManager {
|
||||||
private tokens = new Map<string, IStoredApiToken>();
|
private tokens = new Map<string, IStoredApiToken>();
|
||||||
|
|
||||||
constructor(private storageManager: StorageManager) {}
|
constructor() {}
|
||||||
|
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
await this.loadTokens();
|
await this.loadTokens();
|
||||||
@@ -117,7 +116,8 @@ export class ApiTokenManager {
|
|||||||
if (!this.tokens.has(id)) return false;
|
if (!this.tokens.has(id)) return false;
|
||||||
const token = this.tokens.get(id)!;
|
const token = this.tokens.get(id)!;
|
||||||
this.tokens.delete(id);
|
this.tokens.delete(id);
|
||||||
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
|
const doc = await ApiTokenDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -157,17 +157,48 @@ export class ApiTokenManager {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async loadTokens(): Promise<void> {
|
private async loadTokens(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(TOKENS_PREFIX);
|
const docs = await ApiTokenDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
if (!key.endsWith('.json')) continue;
|
if (doc.id) {
|
||||||
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
|
this.tokens.set(doc.id, {
|
||||||
if (stored?.id) {
|
id: doc.id,
|
||||||
this.tokens.set(stored.id, stored);
|
name: doc.name,
|
||||||
|
tokenHash: doc.tokenHash,
|
||||||
|
scopes: doc.scopes,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
expiresAt: doc.expiresAt,
|
||||||
|
lastUsedAt: doc.lastUsedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||||
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
|
const existing = await ApiTokenDoc.findById(stored.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.name = stored.name;
|
||||||
|
existing.tokenHash = stored.tokenHash;
|
||||||
|
existing.scopes = stored.scopes;
|
||||||
|
existing.createdAt = stored.createdAt;
|
||||||
|
existing.expiresAt = stored.expiresAt;
|
||||||
|
existing.lastUsedAt = stored.lastUsedAt;
|
||||||
|
existing.createdBy = stored.createdBy;
|
||||||
|
existing.enabled = stored.enabled;
|
||||||
|
await existing.save();
|
||||||
|
} else {
|
||||||
|
const doc = new ApiTokenDoc();
|
||||||
|
doc.id = stored.id;
|
||||||
|
doc.name = stored.name;
|
||||||
|
doc.tokenHash = stored.tokenHash;
|
||||||
|
doc.scopes = stored.scopes;
|
||||||
|
doc.createdAt = stored.createdAt;
|
||||||
|
doc.expiresAt = stored.expiresAt;
|
||||||
|
doc.lastUsedAt = stored.lastUsedAt;
|
||||||
|
doc.createdBy = stored.createdBy;
|
||||||
|
doc.enabled = stored.enabled;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,67 +1,119 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
import { RouteDoc } from '../db/index.js';
|
||||||
import type {
|
import type {
|
||||||
IStoredRoute,
|
IRoute,
|
||||||
IRouteOverride,
|
|
||||||
IMergedRoute,
|
IMergedRoute,
|
||||||
IRouteWarning,
|
IRouteWarning,
|
||||||
|
IRouteMetadata,
|
||||||
} from '../../ts_interfaces/data/route-management.js';
|
} 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';
|
||||||
|
|
||||||
const ROUTES_PREFIX = '/config-api/routes/';
|
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
||||||
const OVERRIDES_PREFIX = '/config-api/overrides/';
|
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
||||||
|
|
||||||
|
export interface IRouteMutationResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
||||||
|
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
||||||
|
*/
|
||||||
|
class RouteUpdateMutex {
|
||||||
|
private locked = false;
|
||||||
|
private queue: Array<() => void> = [];
|
||||||
|
|
||||||
|
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (!this.locked) {
|
||||||
|
this.locked = true;
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
this.queue.push(resolve);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
this.locked = false;
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next) {
|
||||||
|
this.locked = true;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class RouteConfigManager {
|
export class RouteConfigManager {
|
||||||
private storedRoutes = new Map<string, IStoredRoute>();
|
private routes = new Map<string, IRoute>();
|
||||||
private overrides = new Map<string, IRouteOverride>();
|
|
||||||
private warnings: IRouteWarning[] = [];
|
private warnings: IRouteWarning[] = [];
|
||||||
|
private routeUpdateMutex = new RouteUpdateMutex();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private storageManager: StorageManager,
|
|
||||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
|
||||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
|
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||||
|
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||||
|
private referenceResolver?: ReferenceResolver,
|
||||||
|
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | 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 setVpnClientIpsResolver(
|
||||||
|
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||||
|
): void {
|
||||||
|
this.getVpnClientIpsForRoute = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
* 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(): Promise<void> {
|
public async initialize(
|
||||||
await this.loadStoredRoutes();
|
configRoutes: IDcRouterRouteConfig[] = [],
|
||||||
await this.loadOverrides();
|
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.computeWarnings();
|
||||||
this.logWarnings();
|
this.logWarnings();
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Merged view
|
// Route listing
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||||
const merged: IMergedRoute[] = [];
|
const merged: IMergedRoute[] = [];
|
||||||
|
|
||||||
// Hardcoded routes
|
for (const route of this.routes.values()) {
|
||||||
for (const route of this.getHardcodedRoutes()) {
|
|
||||||
const name = route.name || '';
|
|
||||||
const override = this.overrides.get(name);
|
|
||||||
merged.push({
|
merged.push({
|
||||||
route,
|
route: route.route,
|
||||||
source: 'hardcoded',
|
id: route.id,
|
||||||
enabled: override ? override.enabled : true,
|
enabled: route.enabled,
|
||||||
overridden: !!override,
|
origin: route.origin,
|
||||||
});
|
systemKey: route.systemKey,
|
||||||
}
|
createdAt: route.createdAt,
|
||||||
|
updatedAt: route.updatedAt,
|
||||||
// Programmatic routes
|
metadata: route.metadata,
|
||||||
for (const stored of this.storedRoutes.values()) {
|
|
||||||
merged.push({
|
|
||||||
route: stored.route,
|
|
||||||
source: 'programmatic',
|
|
||||||
enabled: stored.enabled,
|
|
||||||
overridden: false,
|
|
||||||
storedRouteId: stored.id,
|
|
||||||
createdAt: stored.createdAt,
|
|
||||||
updatedAt: stored.updatedAt,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,32 +121,43 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Programmatic route CRUD
|
// Route CRUD
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async createRoute(
|
public async createRoute(
|
||||||
route: plugins.smartproxy.IRouteConfig,
|
route: IDcRouterRouteConfig,
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
metadata?: IRouteMetadata,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Ensure route has a name
|
// Ensure route has a name
|
||||||
if (!route.name) {
|
if (!route.name) {
|
||||||
route.name = `programmatic-${id.slice(0, 8)}`;
|
route.name = `route-${id.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored: IStoredRoute = {
|
// 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,
|
id,
|
||||||
route,
|
route,
|
||||||
enabled,
|
enabled,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy,
|
createdBy,
|
||||||
|
origin: 'api',
|
||||||
|
metadata: resolvedMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.storedRoutes.set(id, stored);
|
this.routes.set(id, stored);
|
||||||
await this.persistRoute(stored);
|
await this.persistRoute(stored);
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
return id;
|
return id;
|
||||||
@@ -102,96 +165,301 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
public async updateRoute(
|
public async updateRoute(
|
||||||
id: string,
|
id: string,
|
||||||
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
|
patch: {
|
||||||
): Promise<boolean> {
|
route?: Partial<IDcRouterRouteConfig>;
|
||||||
const stored = this.storedRoutes.get(id);
|
enabled?: boolean;
|
||||||
if (!stored) return false;
|
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) {
|
if (patch.route) {
|
||||||
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
|
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) {
|
if (patch.enabled !== undefined) {
|
||||||
stored.enabled = patch.enabled;
|
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();
|
stored.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.persistRoute(stored);
|
await this.persistRoute(stored);
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
return true;
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteRoute(id: string): Promise<boolean> {
|
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
|
||||||
if (!this.storedRoutes.has(id)) return false;
|
const stored = this.routes.get(id);
|
||||||
this.storedRoutes.delete(id);
|
if (!stored) {
|
||||||
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
|
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();
|
await this.applyRoutes();
|
||||||
return true;
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
|
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
|
||||||
return this.updateRoute(id, { enabled });
|
return this.updateRoute(id, { enabled });
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Hardcoded route overrides
|
// Private: seed routes from constructor config
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
/**
|
||||||
const override: IRouteOverride = {
|
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
|
||||||
routeName,
|
* Deletes stale DB routes whose origin matches but name is not in the seed set.
|
||||||
enabled,
|
*/
|
||||||
updatedAt: Date.now(),
|
private async seedRoutes(
|
||||||
updatedBy,
|
seedRoutes: IDcRouterRouteConfig[],
|
||||||
};
|
origin: 'config' | 'email' | 'dns',
|
||||||
this.overrides.set(routeName, override);
|
): Promise<void> {
|
||||||
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
|
const seedSystemKeys = new Set<string>();
|
||||||
this.computeWarnings();
|
const seedNames = new Set<string>();
|
||||||
await this.applyRoutes();
|
let seeded = 0;
|
||||||
}
|
let updated = 0;
|
||||||
|
|
||||||
public async removeOverride(routeName: string): Promise<boolean> {
|
for (const route of seedRoutes) {
|
||||||
if (!this.overrides.has(routeName)) return false;
|
const name = route.name || '';
|
||||||
this.overrides.delete(routeName);
|
if (name) {
|
||||||
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
|
seedNames.add(name);
|
||||||
this.computeWarnings();
|
}
|
||||||
await this.applyRoutes();
|
const systemKey = this.buildSystemRouteKey(origin, route);
|
||||||
return true;
|
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: persistence
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async loadStoredRoutes(): Promise<void> {
|
private buildSystemRouteKey(
|
||||||
const keys = await this.storageManager.list(ROUTES_PREFIX);
|
origin: 'config' | 'email' | 'dns',
|
||||||
for (const key of keys) {
|
route: IDcRouterRouteConfig,
|
||||||
if (!key.endsWith('.json')) continue;
|
): string | undefined {
|
||||||
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
|
const name = route.name?.trim();
|
||||||
if (stored?.id) {
|
if (!name) return undefined;
|
||||||
this.storedRoutes.set(stored.id, stored);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.storedRoutes.size > 0) {
|
|
||||||
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
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 loadOverrides(): Promise<void> {
|
private async persistRoute(stored: IRoute): Promise<void> {
|
||||||
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
|
const existingDoc = await RouteDoc.findById(stored.id);
|
||||||
for (const key of keys) {
|
if (existingDoc) {
|
||||||
if (!key.endsWith('.json')) continue;
|
existingDoc.route = stored.route;
|
||||||
const override = await this.storageManager.getJSON<IRouteOverride>(key);
|
existingDoc.enabled = stored.enabled;
|
||||||
if (override?.routeName) {
|
existingDoc.updatedAt = stored.updatedAt;
|
||||||
this.overrides.set(override.routeName, override);
|
existingDoc.createdBy = stored.createdBy;
|
||||||
}
|
existingDoc.origin = stored.origin;
|
||||||
}
|
existingDoc.systemKey = stored.systemKey;
|
||||||
if (this.overrides.size > 0) {
|
existingDoc.metadata = stored.metadata;
|
||||||
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
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 async persistRoute(stored: IStoredRoute): Promise<void> {
|
private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
|
||||||
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!normalized.sourceProfileRef) {
|
||||||
|
normalized.sourceProfileName = undefined;
|
||||||
|
}
|
||||||
|
if (!normalized.networkTargetRef) {
|
||||||
|
normalized.networkTargetName = undefined;
|
||||||
|
}
|
||||||
|
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
||||||
|
normalized.lastResolvedAt = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.values(normalized).every((value) => value === undefined)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -200,33 +468,14 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
private computeWarnings(): void {
|
private computeWarnings(): void {
|
||||||
this.warnings = [];
|
this.warnings = [];
|
||||||
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
|
||||||
|
|
||||||
// Check overrides
|
for (const route of this.routes.values()) {
|
||||||
for (const [routeName, override] of this.overrides) {
|
if (!route.enabled) {
|
||||||
if (!hardcodedNames.has(routeName)) {
|
const name = route.route.name || route.id;
|
||||||
this.warnings.push({
|
this.warnings.push({
|
||||||
type: 'orphaned-override',
|
type: 'disabled-route',
|
||||||
routeName,
|
|
||||||
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
|
||||||
});
|
|
||||||
} else if (!override.enabled) {
|
|
||||||
this.warnings.push({
|
|
||||||
type: 'disabled-hardcoded',
|
|
||||||
routeName,
|
|
||||||
message: `Route '${routeName}' is disabled via API override`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check disabled programmatic routes
|
|
||||||
for (const stored of this.storedRoutes.values()) {
|
|
||||||
if (!stored.enabled) {
|
|
||||||
const name = stored.route.name || stored.id;
|
|
||||||
this.warnings.push({
|
|
||||||
type: 'disabled-programmatic',
|
|
||||||
routeName: name,
|
routeName: name,
|
||||||
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
message: `Route '${name}' (id: ${route.id}) is disabled`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,33 +488,102 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: apply merged routes to SmartProxy
|
// Re-resolve routes after profile/target changes
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async applyRoutes(): Promise<void> {
|
/**
|
||||||
const smartProxy = this.getSmartProxy();
|
* Re-resolve specific routes by ID (after a profile or target is updated).
|
||||||
if (!smartProxy) return;
|
* Persists each route and calls applyRoutes() once at the end.
|
||||||
|
*/
|
||||||
|
public async reResolveRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||||
|
|
||||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
for (const routeId of routeIds) {
|
||||||
|
const stored = this.routes.get(routeId);
|
||||||
|
if (!stored?.metadata) continue;
|
||||||
|
|
||||||
// Add enabled hardcoded routes (respecting overrides)
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
for (const route of this.getHardcodedRoutes()) {
|
stored.route = resolved.route;
|
||||||
const name = route.name || '';
|
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||||
const override = this.overrides.get(name);
|
stored.updatedAt = Date.now();
|
||||||
if (override && !override.enabled) {
|
await this.persistRoute(stored);
|
||||||
continue; // Skip disabled hardcoded route
|
|
||||||
}
|
|
||||||
enabledRoutes.push(route);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add enabled programmatic routes
|
await this.applyRoutes();
|
||||||
for (const stored of this.storedRoutes.values()) {
|
logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`);
|
||||||
if (stored.enabled) {
|
}
|
||||||
enabledRoutes.push(stored.route);
|
|
||||||
|
// =========================================================================
|
||||||
|
// 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await smartProxy.updateRoutes(enabledRoutes);
|
return this.injectVpnSecurity(preparedRoute, routeId);
|
||||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
}
|
||||||
|
|
||||||
|
private injectVpnSecurity(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
routeId?: string,
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||||
|
if (!vpnCallback) return route;
|
||||||
|
|
||||||
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
|
if (!dcRoute.vpnOnly) return route;
|
||||||
|
|
||||||
|
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||||
|
const existingEntries = route.security?.ipAllowList || [];
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
|
||||||
|
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
|
||||||
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
|
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages TargetProfiles (target-side: what can be accessed).
|
||||||
|
* TargetProfiles define what resources a VPN client can reach:
|
||||||
|
* domains, specific IP:port targets, and/or direct route references.
|
||||||
|
*/
|
||||||
|
export class TargetProfileManager {
|
||||||
|
private profiles = new Map<string, ITargetProfile>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private getAllRoutes?: () => Map<string, IRoute>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createProfile(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
domains?: string[];
|
||||||
|
targets?: ITargetProfileTarget[];
|
||||||
|
routeRefs?: string[];
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
// Enforce unique profile names
|
||||||
|
for (const existing of this.profiles.values()) {
|
||||||
|
if (existing.name === data.name) {
|
||||||
|
throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||||
|
const profile: ITargetProfile = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
domains: data.domains,
|
||||||
|
targets: data.targets,
|
||||||
|
routeRefs,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.profiles.set(id, profile);
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Created target profile '${profile.name}' (${id})`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProfile(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<ITargetProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||||
|
): Promise<void> {
|
||||||
|
const profile = this.profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`Target profile '${id}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined && patch.name !== profile.name) {
|
||||||
|
for (const existing of this.profiles.values()) {
|
||||||
|
if (existing.id !== id && existing.name === patch.name) {
|
||||||
|
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined) profile.name = patch.name;
|
||||||
|
if (patch.description !== undefined) profile.description = patch.description;
|
||||||
|
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||||
|
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||||
|
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
||||||
|
profile.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Updated target profile '${profile.name}' (${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteProfile(
|
||||||
|
id: string,
|
||||||
|
force?: boolean,
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const profile = this.profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, message: `Target profile '${id}' not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any VPN clients reference this profile
|
||||||
|
const clients = await VpnClientDoc.findAll();
|
||||||
|
const referencingClients = clients.filter(
|
||||||
|
(c) => c.targetProfileIds?.includes(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (referencingClients.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Profile '${profile.name}' is in use by ${referencingClients.length} VPN client(s). Use force=true to delete.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from DB
|
||||||
|
const doc = await TargetProfileDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
this.profiles.delete(id);
|
||||||
|
|
||||||
|
if (referencingClients.length > 0) {
|
||||||
|
// Remove profile ref from clients
|
||||||
|
for (const client of referencingClients) {
|
||||||
|
client.targetProfileIds = client.targetProfileIds?.filter((pid) => pid !== id);
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await client.save();
|
||||||
|
}
|
||||||
|
logger.log('warn', `Force-deleted target profile '${profile.name}'; removed refs from ${referencingClients.length} client(s)`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `Deleted target profile '${profile.name}' (${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfile(id: string): ITargetProfile | undefined {
|
||||||
|
return this.profiles.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize stored route references to route IDs when they can be resolved
|
||||||
|
* uniquely against the current route registry.
|
||||||
|
*/
|
||||||
|
public async normalizeAllRouteRefs(): Promise<void> {
|
||||||
|
const allRoutes = this.getAllRoutes?.();
|
||||||
|
if (!allRoutes?.size) return;
|
||||||
|
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
||||||
|
profile.routeRefs,
|
||||||
|
allRoutes,
|
||||||
|
'bestEffort',
|
||||||
|
);
|
||||||
|
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
||||||
|
|
||||||
|
profile.routeRefs = normalizedRouteRefs;
|
||||||
|
profile.updatedAt = Date.now();
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public listProfiles(): ITargetProfile[] {
|
||||||
|
return [...this.profiles.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which VPN clients reference a target profile.
|
||||||
|
*/
|
||||||
|
public async getProfileUsage(profileId: string): Promise<Array<{ clientId: string; description?: string }>> {
|
||||||
|
const clients = await VpnClientDoc.findAll();
|
||||||
|
return clients
|
||||||
|
.filter((c) => c.targetProfileIds?.includes(profileId))
|
||||||
|
.map((c) => ({ clientId: c.clientId, description: c.description }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Direct target IPs (bypass SmartProxy)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a set of target profile IDs, collect all explicit target IPs.
|
||||||
|
* These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
|
||||||
|
* connect to them directly through the tunnel.
|
||||||
|
*/
|
||||||
|
public getDirectTargetIps(targetProfileIds: string[]): string[] {
|
||||||
|
const ips = new Set<string>();
|
||||||
|
for (const profileId of targetProfileIds) {
|
||||||
|
const profile = this.profiles.get(profileId);
|
||||||
|
if (!profile?.targets?.length) continue;
|
||||||
|
for (const t of profile.targets) {
|
||||||
|
ips.add(t.ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ips];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Core matching: route → client IPs
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
|
||||||
|
* matches the route. Returns IP allow entries for injection into ipAllowList.
|
||||||
|
*
|
||||||
|
* Entries are domain-scoped when a profile matches via specific domains that are
|
||||||
|
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
|
||||||
|
* or when profile domains exactly equal the route's domains.
|
||||||
|
*/
|
||||||
|
public getMatchingClientIps(
|
||||||
|
route: IDcRouterRouteConfig,
|
||||||
|
routeId: string | undefined,
|
||||||
|
clients: VpnClientDoc[],
|
||||||
|
allRoutes: Map<string, IRoute> = new Map(),
|
||||||
|
): Array<string | { ip: string; domains: string[] }> {
|
||||||
|
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||||
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
if (!client.enabled || !client.assignedIp) continue;
|
||||||
|
if (!client.targetProfileIds?.length) continue;
|
||||||
|
|
||||||
|
// Collect scoped domains from all matching profiles for this client
|
||||||
|
let fullAccess = false;
|
||||||
|
const scopedDomains = new Set<string>();
|
||||||
|
|
||||||
|
for (const profileId of client.targetProfileIds) {
|
||||||
|
const profile = this.profiles.get(profileId);
|
||||||
|
if (!profile) continue;
|
||||||
|
|
||||||
|
const matchResult = this.routeMatchesProfileDetailed(
|
||||||
|
route,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeDomains,
|
||||||
|
routeNameIndex,
|
||||||
|
);
|
||||||
|
if (matchResult === 'full') {
|
||||||
|
fullAccess = true;
|
||||||
|
break; // No need to check more profiles
|
||||||
|
}
|
||||||
|
if (matchResult !== 'none') {
|
||||||
|
for (const d of matchResult.domains) scopedDomains.add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullAccess) {
|
||||||
|
entries.push(client.assignedIp);
|
||||||
|
} else if (scopedDomains.size > 0) {
|
||||||
|
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a given client (by its targetProfileIds), compute the set of
|
||||||
|
* domains and target IPs it can access. Used for WireGuard AllowedIPs.
|
||||||
|
*/
|
||||||
|
public getClientAccessSpec(
|
||||||
|
targetProfileIds: string[],
|
||||||
|
allRoutes: Map<string, IRoute>,
|
||||||
|
): { domains: string[]; targetIps: string[] } {
|
||||||
|
const domains = new Set<string>();
|
||||||
|
const targetIps = new Set<string>();
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
|
// Collect all access specifiers from assigned profiles
|
||||||
|
for (const profileId of targetProfileIds) {
|
||||||
|
const profile = this.profiles.get(profileId);
|
||||||
|
if (!profile) continue;
|
||||||
|
|
||||||
|
// Direct domain entries
|
||||||
|
if (profile.domains?.length) {
|
||||||
|
for (const d of profile.domains) {
|
||||||
|
domains.add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct target IP entries
|
||||||
|
if (profile.targets?.length) {
|
||||||
|
for (const t of profile.targets) {
|
||||||
|
targetIps.add(t.ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route references: scan all routes
|
||||||
|
for (const [routeId, route] of allRoutes) {
|
||||||
|
if (!route.enabled) continue;
|
||||||
|
if (this.routeMatchesProfile(
|
||||||
|
route.route as IDcRouterRouteConfig,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeNameIndex,
|
||||||
|
)) {
|
||||||
|
const routeDomains = (route.route.match as any)?.domains;
|
||||||
|
if (Array.isArray(routeDomains)) {
|
||||||
|
for (const d of routeDomains) {
|
||||||
|
domains.add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domains: [...domains],
|
||||||
|
targetIps: [...targetIps],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: matching logic
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a route matches a profile (boolean convenience wrapper).
|
||||||
|
*/
|
||||||
|
private routeMatchesProfile(
|
||||||
|
route: IDcRouterRouteConfig,
|
||||||
|
routeId: string | undefined,
|
||||||
|
profile: ITargetProfile,
|
||||||
|
routeNameIndex: Map<string, string[]>,
|
||||||
|
): boolean {
|
||||||
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||||
|
const result = this.routeMatchesProfileDetailed(
|
||||||
|
route,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeDomains,
|
||||||
|
routeNameIndex,
|
||||||
|
);
|
||||||
|
return result !== 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited),
|
||||||
|
* or 'none' (no match).
|
||||||
|
*
|
||||||
|
* - routeRefs / target matches → 'full' (explicit reference = full access)
|
||||||
|
* - domain match where profile domains are a subset of route wildcard → 'scoped'
|
||||||
|
* - domain match where domains are identical or profile is a wildcard → 'full'
|
||||||
|
*/
|
||||||
|
private routeMatchesProfileDetailed(
|
||||||
|
route: IDcRouterRouteConfig,
|
||||||
|
routeId: string | undefined,
|
||||||
|
profile: ITargetProfile,
|
||||||
|
routeDomains: string[],
|
||||||
|
routeNameIndex: Map<string, string[]>,
|
||||||
|
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||||
|
// 1. Route reference match → full access
|
||||||
|
if (profile.routeRefs?.length) {
|
||||||
|
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
||||||
|
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
||||||
|
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
||||||
|
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Domain match
|
||||||
|
if (profile.domains?.length && routeDomains.length) {
|
||||||
|
const matchedProfileDomains: string[] = [];
|
||||||
|
|
||||||
|
for (const profileDomain of profile.domains) {
|
||||||
|
for (const routeDomain of routeDomains) {
|
||||||
|
if (this.domainMatchesPattern(routeDomain, profileDomain) ||
|
||||||
|
this.domainMatchesPattern(profileDomain, routeDomain)) {
|
||||||
|
matchedProfileDomains.push(profileDomain);
|
||||||
|
break; // This profileDomain matched, move to the next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedProfileDomains.length > 0) {
|
||||||
|
// Check if profile domains cover the route entirely (same wildcards = full access)
|
||||||
|
const isFullCoverage = routeDomains.every((rd) =>
|
||||||
|
matchedProfileDomains.some((pd) =>
|
||||||
|
rd === pd || this.domainMatchesPattern(rd, pd),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (isFullCoverage) return 'full';
|
||||||
|
|
||||||
|
// Profile domains are a subset → scoped access to those specific domains
|
||||||
|
return { type: 'scoped', domains: matchedProfileDomains };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Target match (host + port) → full access (precise by nature)
|
||||||
|
if (profile.targets?.length) {
|
||||||
|
const routeTargets = (route.action as any)?.targets;
|
||||||
|
if (Array.isArray(routeTargets)) {
|
||||||
|
for (const profileTarget of profile.targets) {
|
||||||
|
for (const routeTarget of routeTargets) {
|
||||||
|
const routeHost = routeTarget.host;
|
||||||
|
const routePort = routeTarget.port;
|
||||||
|
if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain matches a pattern.
|
||||||
|
* - '*.example.com' matches 'sub.example.com', 'a.b.example.com'
|
||||||
|
* - 'example.com' matches only 'example.com'
|
||||||
|
*/
|
||||||
|
private domainMatchesPattern(domain: string, pattern: string): boolean {
|
||||||
|
if (pattern === domain) return true;
|
||||||
|
if (pattern.startsWith('*.')) {
|
||||||
|
const suffix = pattern.slice(1); // '.example.com'
|
||||||
|
return domain.endsWith(suffix) && domain.length > suffix.length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||||
|
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||||
|
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteRefsAgainstRoutes(
|
||||||
|
routeRefs: string[] | undefined,
|
||||||
|
allRoutes: Map<string, IRoute>,
|
||||||
|
mode: 'strict' | 'bestEffort',
|
||||||
|
): string[] | undefined {
|
||||||
|
if (!routeRefs?.length) return undefined;
|
||||||
|
if (!allRoutes.size) return [...new Set(routeRefs)];
|
||||||
|
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
const normalizedRefs = new Set<string>();
|
||||||
|
|
||||||
|
for (const routeRef of routeRefs) {
|
||||||
|
if (allRoutes.has(routeRef)) {
|
||||||
|
normalizedRefs.add(routeRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
||||||
|
if (matchingRouteIds.length === 1) {
|
||||||
|
normalizedRefs.add(matchingRouteIds[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'bestEffort') {
|
||||||
|
normalizedRefs.add(routeRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingRouteIds.length > 1) {
|
||||||
|
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
||||||
|
}
|
||||||
|
throw new Error(`Route reference '${routeRef}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...normalizedRefs];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
||||||
|
const routeNameIndex = new Map<string, string[]>();
|
||||||
|
for (const [routeId, route] of allRoutes) {
|
||||||
|
const routeName = route.route.name;
|
||||||
|
if (!routeName) continue;
|
||||||
|
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
||||||
|
matchingRouteIds.push(routeId);
|
||||||
|
routeNameIndex.set(routeName, matchingRouteIds);
|
||||||
|
}
|
||||||
|
return routeNameIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameStringArray(left?: string[], right?: string[]): boolean {
|
||||||
|
if (!left?.length && !right?.length) return true;
|
||||||
|
if (!left || !right || left.length !== right.length) return false;
|
||||||
|
return left.every((value, index) => value === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadProfiles(): Promise<void> {
|
||||||
|
const docs = await TargetProfileDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.profiles.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
domains: doc.domains,
|
||||||
|
targets: doc.targets,
|
||||||
|
routeRefs: doc.routeRefs,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.profiles.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistProfile(profile: ITargetProfile): Promise<void> {
|
||||||
|
const existingDoc = await TargetProfileDoc.findById(profile.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.name = profile.name;
|
||||||
|
existingDoc.description = profile.description;
|
||||||
|
existingDoc.domains = profile.domains;
|
||||||
|
existingDoc.targets = profile.targets;
|
||||||
|
existingDoc.routeRefs = profile.routeRefs;
|
||||||
|
existingDoc.updatedAt = profile.updatedAt;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new TargetProfileDoc();
|
||||||
|
doc.id = profile.id;
|
||||||
|
doc.name = profile.name;
|
||||||
|
doc.description = profile.description;
|
||||||
|
doc.domains = profile.domains;
|
||||||
|
doc.targets = profile.targets;
|
||||||
|
doc.routeRefs = profile.routeRefs;
|
||||||
|
doc.createdAt = profile.createdAt;
|
||||||
|
doc.updatedAt = profile.updatedAt;
|
||||||
|
doc.createdBy = profile.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -1,4 +1,7 @@
|
|||||||
// Export validation tools only
|
// Export validation tools only
|
||||||
export * from './validator.js';
|
export * from './validator.js';
|
||||||
export { RouteConfigManager } from './classes.route-config-manager.js';
|
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||||
export { ApiTokenManager } from './classes.api-token-manager.js';
|
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||||
|
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
export { DbSeeder } from './classes.db-seeder.js';
|
||||||
|
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||||
@@ -170,7 +170,7 @@ export class ConfigValidator {
|
|||||||
} else if (rules.items.schema && itemType === 'object') {
|
} else if (rules.items.schema && itemType === 'object') {
|
||||||
const itemResult = this.validate(value[i], rules.items.schema);
|
const itemResult = this.validate(value[i], rules.items.schema);
|
||||||
if (!itemResult.valid) {
|
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) {
|
if (rules.schema) {
|
||||||
const nestedResult = this.validate(value, rules.schema);
|
const nestedResult = this.validate(value, rules.schema);
|
||||||
if (!nestedResult.valid) {
|
if (!nestedResult.valid) {
|
||||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
|
||||||
}
|
}
|
||||||
validatedConfig[key] = nestedResult.config;
|
validatedConfig[key] = nestedResult.config;
|
||||||
}
|
}
|
||||||
@@ -233,8 +233,8 @@ export class ConfigValidator {
|
|||||||
|
|
||||||
// Apply defaults to array items
|
// Apply defaults to array items
|
||||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||||
result[key] = result[key].map(item =>
|
result[key] = result[key].map(item =>
|
||||||
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ export class ConfigValidator {
|
|||||||
|
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
`Configuration validation failed: ${result.errors!.join(', ')}`,
|
||||||
'CONFIG_VALIDATION_ERROR',
|
'CONFIG_VALIDATION_ERROR',
|
||||||
{ data: { errors: result.errors } }
|
{ data: { errors: result.errors } }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.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 document classes for cleanup
|
||||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||||
@@ -26,10 +26,10 @@ export class CacheCleaner {
|
|||||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private isRunning: boolean = false;
|
private isRunning: boolean = false;
|
||||||
private options: Required<ICacheCleanerOptions>;
|
private options: Required<ICacheCleanerOptions>;
|
||||||
private cacheDb: CacheDb;
|
private dcRouterDb: DcRouterDb;
|
||||||
|
|
||||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
constructor(dcRouterDb: DcRouterDb, options: ICacheCleanerOptions = {}) {
|
||||||
this.cacheDb = cacheDb;
|
this.dcRouterDb = dcRouterDb;
|
||||||
this.options = {
|
this.options = {
|
||||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||||
verbose: options.verbose || false,
|
verbose: options.verbose || false,
|
||||||
@@ -48,14 +48,14 @@ export class CacheCleaner {
|
|||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
// Run cleanup immediately on start
|
// Run cleanup immediately on start
|
||||||
this.runCleanup().catch((error) => {
|
this.runCleanup().catch((error: unknown) => {
|
||||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schedule periodic cleanup
|
// Schedule periodic cleanup
|
||||||
this.cleanupInterval = setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
this.runCleanup().catch((error) => {
|
this.runCleanup().catch((error: unknown) => {
|
||||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
|
||||||
});
|
});
|
||||||
}, this.options.intervalMs);
|
}, this.options.intervalMs);
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@ export class CacheCleaner {
|
|||||||
* Run a single cleanup cycle
|
* Run a single cleanup cycle
|
||||||
*/
|
*/
|
||||||
public async runCleanup(): Promise<void> {
|
public async runCleanup(): Promise<void> {
|
||||||
if (!this.cacheDb.isReady()) {
|
if (!this.dcRouterDb.isReady()) {
|
||||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
logger.log('warn', 'DcRouterDb not ready, skipping cleanup');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +113,8 @@ export class CacheCleaner {
|
|||||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,14 +138,14 @@ export class CacheCleaner {
|
|||||||
try {
|
try {
|
||||||
await doc.delete();
|
await doc.delete();
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
} catch (deleteError) {
|
} catch (deleteError: unknown) {
|
||||||
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
|
||||||
return 0;
|
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
|
* Timestamp when the document expires and should be cleaned up
|
||||||
* NOTE: Subclasses must add @svDb() decorator
|
* NOTE: Subclasses must add @svDb() decorator
|
||||||
*/
|
*/
|
||||||
public expiresAt: Date;
|
public expiresAt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp of last access (for LRU-style eviction if needed)
|
* 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,56 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, ApiTokenDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public tokenHash!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public scopes!: TApiTokenScope[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt!: number | null;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastUsedAt!: number | null;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<ApiTokenDoc | null> {
|
||||||
|
return await ApiTokenDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByTokenHash(tokenHash: string): Promise<ApiTokenDoc | null> {
|
||||||
|
return await ApiTokenDoc.getInstance({ tokenHash });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<ApiTokenDoc[]> {
|
||||||
|
return await ApiTokenDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findEnabled(): Promise<ApiTokenDoc[]> {
|
||||||
|
return await ApiTokenDoc.getInstances({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-16
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { CachedDocument, TTL } from '../classes.cached.document.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
|
* Email status in the cache
|
||||||
@@ -10,7 +10,7 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'faile
|
|||||||
/**
|
/**
|
||||||
* Helper to get the smartdata database instance
|
* 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
|
* CachedEmail - Stores email queue items in the cache
|
||||||
@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
*/
|
*/
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public id: string;
|
public id!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email message ID (RFC 822 Message-ID header)
|
* Email message ID (RFC 822 Message-ID header)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public messageId: string;
|
public messageId!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sender email address (envelope from)
|
* Sender email address (envelope from)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public from: string;
|
public from!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recipient email addresses
|
* Recipient email addresses
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public to: string[];
|
public to!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CC recipients
|
* CC recipients
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public cc: string[];
|
public cc!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BCC recipients
|
* BCC recipients
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public bcc: string[];
|
public bcc!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email subject
|
* Email subject
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public subject: string;
|
public subject!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw RFC822 email content
|
* Raw RFC822 email content
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public rawContent: string;
|
public rawContent!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current status of the email
|
* Current status of the email
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public status: TCachedEmailStatus;
|
public status!: TCachedEmailStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of delivery attempts
|
* Number of delivery attempts
|
||||||
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
* Timestamp for next delivery attempt
|
* Timestamp for next delivery attempt
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public nextAttempt: Date;
|
public nextAttempt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last error message if delivery failed
|
* Last error message if delivery failed
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public lastError: string;
|
public lastError!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp when the email was successfully delivered
|
* Timestamp when the email was successfully delivered
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public deliveredAt: Date;
|
public deliveredAt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sender domain (for querying/filtering)
|
* Sender domain (for querying/filtering)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public senderDomain: string;
|
public senderDomain!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Priority level (higher = more important)
|
* Priority level (higher = more important)
|
||||||
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
* JSON-serialized route data
|
* JSON-serialized route data
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public routeData: string;
|
public routeData!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DKIM signature status
|
* DKIM signature status
|
||||||
+12
-12
@@ -1,11 +1,11 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { CachedDocument, TTL } from '../classes.cached.document.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
|
* Helper to get the smartdata database instance
|
||||||
*/
|
*/
|
||||||
const getDb = () => CacheDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IP reputation result data
|
* IP reputation result data
|
||||||
@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
|||||||
*/
|
*/
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public ipAddress: string;
|
public ipAddress!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reputation score (0-100, higher = better)
|
* Reputation score (0-100, higher = better)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public score: number;
|
public score!: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is flagged as spam source
|
* Whether the IP is flagged as spam source
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isSpam: boolean;
|
public isSpam!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a known proxy
|
* Whether the IP is a known proxy
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isProxy: boolean;
|
public isProxy!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a Tor exit node
|
* Whether the IP is a Tor exit node
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isTor: boolean;
|
public isTor!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a VPN endpoint
|
* Whether the IP is a VPN endpoint
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isVPN: boolean;
|
public isVPN!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Country code (ISO 3166-1 alpha-2)
|
* Country code (ISO 3166-1 alpha-2)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public country: string;
|
public country!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autonomous System Number
|
* Autonomous System Number
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public asn: string;
|
public asn!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organization name
|
* Organization name
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public org: string;
|
public org!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of blacklists the IP appears on
|
* List of blacklists the IP appears on
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public blacklists: string[];
|
public blacklists!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of times this IP has been checked
|
* 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,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,48 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { ITargetProfileTarget } from '../../../ts_interfaces/data/target-profile.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetProfileDoc, TargetProfileDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domains?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public targets?: ITargetProfileTarget[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public routeRefs?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<TargetProfileDoc | null> {
|
||||||
|
return await TargetProfileDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<TargetProfileDoc[]> {
|
||||||
|
return await TargetProfileDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,40 @@
|
|||||||
|
// 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.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
|
// Unified database manager
|
||||||
export * from './classes.cachedb.js';
|
export * from './classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
// TTL base class and constants
|
||||||
export * from './classes.cached.document.js';
|
export * from './classes.cached.document.js';
|
||||||
|
|
||||||
|
// Cache cleaner
|
||||||
export * from './classes.cache.cleaner.js';
|
export * from './classes.cache.cleaner.js';
|
||||||
|
|
||||||
// Document classes
|
// 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