Compare commits

..

5 Commits

Author SHA1 Message Date
jkunz 5fbe2eb80b feat: add workapp mail sync API 2026-04-29 16:29:38 +00:00
jkunz a22cc1c0eb feat: add workhoster gateway API 2026-04-29 15:18:14 +00:00
jkunz 4ea339b85a fix: modernize docker publishing 2026-04-29 10:03:34 +00:00
jkunz df9cc3e49b v13.25.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 20:49:57 +00:00
jkunz 7f3ab2499d feat(security): compile network ranges and CIDR arrays into edge firewall policies 2026-04-26 20:49:57 +00:00
35 changed files with 2209 additions and 192 deletions
+10 -46
View File
@@ -1,4 +1,4 @@
name: Docker (tags)
name: Docker (non-tag pushes)
on:
push:
@@ -8,42 +8,10 @@ on:
env:
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
security:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Install pnpm and npmci
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
@@ -54,18 +22,14 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
pnpm install -g @git.zone/tsdocker@latest
pnpm install
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test
run: pnpm test
- name: Test build
run: |
npmci npm prepare
npmci node install stable
npmci npm install
npmci command npm run build
- name: Build image
run: tsdocker build
- name: Test image
run: tsdocker test
+14 -77
View File
@@ -8,73 +8,13 @@ on:
env:
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
security:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci command npm run build
release:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:dbase_dind
image: code.foss.global/host.today/ht-docker-dbase:szci
steps:
- uses: actions/checkout@v3
@@ -82,23 +22,20 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @git.zone/tsdocker
pnpm install -g @git.zone/tsdocker@latest
pnpm install
- name: Release
run: |
tsdocker login
tsdocker build
tsdocker push
- name: Login to registries
run: tsdocker login
metadata:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
- name: List images
run: tsdocker list
steps:
- uses: actions/checkout@v3
- name: Build images
run: tsdocker build
- name: Trigger
run: npmci trigger
- name: Test images
run: tsdocker test
- name: Push to code.foss.global
run: tsdocker push code.foss.global
+2 -10
View File
@@ -30,7 +30,7 @@
"@git.zone/cli": {
"projectType": "service",
"module": {
"githost": "gitlab.com",
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "dcrouter",
"description": "A traffic router intended to be gating your datacenter.",
@@ -67,18 +67,10 @@
"accessLevel": "public"
}
},
"@ship.zone/szci": {
"npmGlobalTools": [],
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
},
"npmRegistryUrl": "verdaccio.lossless.digital"
},
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registryRepoMap": {
"code.foss.global": "serve.zone/dcrouter",
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
"code.foss.global": "serve.zone/dcrouter"
},
"platforms": ["linux/amd64", "linux/arm64"]
}
+9 -6
View File
@@ -1,12 +1,17 @@
# gitzone dockerfile_service
## STAGE 1 // BUILD
FROM code.foss.global/host.today/ht-docker-node:lts AS build
COPY ./ /app
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules && pnpm install
RUN pnpm install --frozen-lockfile
COPY . ./
RUN pnpm run build
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
RUN rm -rf .pnpm-store
RUN pnpm prune --prod
## STAGE 2 // PRODUCTION
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
@@ -18,12 +23,10 @@ WORKDIR /app
COPY --from=build /app /app
ENV DCROUTER_MODE=OCI_CONTAINER
ENV NODE_ENV=production
ENV DCROUTER_HEAP_SIZE=512
ENV UV_THREADPOOL_SIZE=16
RUN pnpm install -g @servezone/healthy
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
LABEL org.opencontainers.image.title="dcrouter" \
org.opencontainers.image.description="Multi-service datacenter gateway" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 2026-04-26 - 13.25.0 - feat(security)
compile network ranges and CIDR arrays into edge firewall policies
- add support for storing intelligence network CIDR arrays alongside single network ranges
- convert start-end IPv4 ranges into CIDR blocks when compiling security policies
- always return an explicit remote ingress firewall snapshot with a blockedIps array
- add tests covering range normalization, ASN-derived CIDRs, and empty firewall snapshots
## 2026-04-26 - 13.24.0 - feat(security)
add security policy management and IP intelligence operations to the ops UI
+4 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.24.0",
"version": "13.25.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -24,6 +24,7 @@
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsdocker": "^2.2.4",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
@@ -51,7 +52,7 @@
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.6.0",
"@push.rocks/smartnetwork": "^4.7.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.9.0",
@@ -63,7 +64,7 @@
"@push.rocks/smartvpn": "1.19.2",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.4.3",
"@serve.zone/interfaces": "^5.4.6",
"@serve.zone/remoteingress": "^4.17.1",
"@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6",
+223 -13
View File
@@ -72,8 +72,8 @@ importers:
specifier: ^5.3.3
version: 5.3.3
'@push.rocks/smartnetwork':
specifier: ^4.6.0
version: 4.6.0
specifier: ^4.7.0
version: 4.7.0
'@push.rocks/smartpath':
specifier: ^6.0.0
version: 6.0.0
@@ -108,8 +108,8 @@ importers:
specifier: ^2.12.4
version: 2.12.4(@tiptap/pm@2.27.2)
'@serve.zone/interfaces':
specifier: ^5.4.3
version: 5.4.3
specifier: ^5.4.6
version: 5.4.6
'@serve.zone/remoteingress':
specifier: ^4.17.1
version: 4.17.1
@@ -135,6 +135,9 @@ importers:
'@git.zone/tsbundle':
specifier: ^2.10.0
version: 2.10.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
'@git.zone/tsdocker':
specifier: ^2.2.4
version: 2.2.4
'@git.zone/tsrun':
specifier: ^2.0.2
version: 2.0.2
@@ -170,6 +173,9 @@ packages:
'@apiclient.xyz/cloudflare@7.1.0':
resolution: {integrity: sha512-qb+PWcE5OjOCPO0+4rexOgtEf9Q1VOIHfrGmav/gXAtkdNL5omifSxPbUseyFKsZrxnRv4rLzvjckUCj0hkvFw==}
'@apiglobal/typedrequest-interfaces@1.0.20':
resolution: {integrity: sha512-ybsDtavYbzGJYSLodSbkxDvSLYtfMzBTuNZDJpiANt1rZA2MO/GCq8zk5MVLlrUUQIr/7oxPGWqxi1QDwR+RHQ==}
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -561,6 +567,10 @@ packages:
resolution: {integrity: sha512-dw2VFlgKssDlCxg92wSPiiAKwfCjJBOEOYXq1xO91OpjQLOkyogCxSLy0jzQ2BYnt4qmBnapjamzYzVjCr4CWg==}
hasBin: true
'@git.zone/tsdocker@2.2.4':
resolution: {integrity: sha512-B5N8I159R0X9NOrYWx3kLQPuIW71uXKzb+RCS4h9N5FSlCOWVPDUU4yuv0dl24lWHsQmSgcnSqPRAUxhSCqZng==}
hasBin: true
'@git.zone/tspublish@1.11.5':
resolution: {integrity: sha512-3tCGhVbH6S/17n3A6Tc6H+ncRdxxbTT0ABcj8S1wRLA8YuBSj9bY7k6uj/iFRy/B/OepB94m1goCiaWESdcZYg==}
hasBin: true
@@ -1213,6 +1223,9 @@ packages:
'@push.rocks/smartlog-interfaces@3.0.2':
resolution: {integrity: sha512-8hGRTJehbsFSJxLhCQkA018mZtXVPxPTblbg9VaE/EqISRzUw+eosJ2EJV7M4Qu0eiTJZjnWnNLn8CkD77ziWw==}
'@push.rocks/smartlog-source-ora@1.0.9':
resolution: {integrity: sha512-s5OmwceGUFbCysYNg3VJZo07lkHxD2GPk8VABJTmhxtrogBw5kChx9d5NMdWQ+CovkNoNhKen1hF3b3l0v6jSQ==}
'@push.rocks/smartlog@3.2.2':
resolution: {integrity: sha512-3Nw/Ki/jZ4vrrWnEtpcGPF28jQ+fr9/9Edc7ytaEA6ZWIpojtwacJ5qihMvHbIei+zjpD35w6tZP2mQjvw5VRQ==}
@@ -1257,8 +1270,8 @@ packages:
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
'@push.rocks/smartnetwork@4.6.0':
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
'@push.rocks/smartnetwork@4.7.0':
resolution: {integrity: sha512-WZ46pJlklDRcw1AqkyyBhmGSNSK3i7IYM9D9vcVJOUhlLmgUSai8o1NbpWlb7HvOkp1IhQ7iZeuJV2JiWLtl1g==}
'@push.rocks/smartnftables@1.1.0':
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
@@ -1404,6 +1417,10 @@ packages:
resolution: {integrity: sha512-9OJbnRgLTaCRQz+pqu5tB3ZCqRs5Zh0hnBe7t7URE+TgwIZ8aiELUIbWRkgn4mSGVzHyL6pqTyIowP6AjUCG3w==}
deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartjson
'@pushrocks/smartlog-interfaces@2.0.23':
resolution: {integrity: sha512-tXqwfrekGxGZJB72BFQppywk7413hXVDgcJNeU+kY6xvmzVjf2HxOMbFYhewh1+p4uai1u9n0xcMb0qbbPy4/Q==}
deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartlog-interfaces
'@pushrocks/smartpromise@3.1.10':
resolution: {integrity: sha512-VeTurbZ1+ZMxBDJk1Y1LV8SN9xLI+oDXKVeCFw41FAGEKOUEqordqFpi6t+7Vhe/TXUZzCVpZ5bXxAxrGf8yTQ==}
deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartpromise
@@ -1594,8 +1611,8 @@ packages:
'@serve.zone/catalog@2.12.4':
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
'@serve.zone/interfaces@5.4.3':
resolution: {integrity: sha512-9ijFhHoC7GYyyAUJbBoDYmcoCmIXTFPiD6fI3x68SWiC0xA+2LG0nOe14D32c1QN9X/3i2Ac5/1sUibfjHsIGg==}
'@serve.zone/interfaces@5.4.6':
resolution: {integrity: sha512-o4k7Wr6t3NLiP6gfAZZz8Jx8RlQ4sZYHTbhr4WkXzGf78vczFRIuFLyY1Y+TTNzDLEIzLVIyMsuECMV1KTwB2Q==}
'@serve.zone/remoteingress@4.17.1':
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
@@ -2154,6 +2171,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@3.2.1:
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
engines: {node: '>=4'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -2331,6 +2352,14 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
chalk@3.0.0:
resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
engines: {node: '>=8'}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
@@ -2356,6 +2385,14 @@ packages:
resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==}
engines: {node: '>= 4.0'}
cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
cli-spinners@2.9.2:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
cli-width@4.1.0:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
@@ -2367,13 +2404,23 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clone@1.0.4:
resolution: {integrity: sha1-2jCcwmPfFZlMaIypAheco8fNfH4=}
engines: {node: '>=0.8'}
cloudflare@5.2.0:
resolution: {integrity: sha512-dVzqDpPFYR9ApEC9e+JJshFJZXcw4HzM8W+3DHzO5oy9+8rLC53G7x6fEf9A7/gSuSCxuvndzui5qJKftfIM9A==}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.3:
resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
@@ -2458,6 +2505,9 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
defer-to-connect@2.0.1:
resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
engines: {node: '>=10'}
@@ -2571,6 +2621,10 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-string-regexp@1.0.5:
resolution: {integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=}
engines: {node: '>=0.8.0'}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -2802,6 +2856,14 @@ packages:
resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==}
engines: {node: '>=18.0.0'}
has-flag@3.0.0:
resolution: {integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0=}
engines: {node: '>=4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-property-descriptors@1.0.2:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
@@ -2924,6 +2986,10 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
is-nan@1.3.2:
resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==}
engines: {node: '>= 0.4'}
@@ -3078,6 +3144,10 @@ packages:
lodash.once@4.1.1:
resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=}
log-symbols@3.0.0:
resolution: {integrity: sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==}
engines: {node: '>=8'}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -3289,6 +3359,10 @@ packages:
engines: {node: '>=16'}
hasBin: true
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
@@ -3400,6 +3474,9 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mute-stream@0.0.8:
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
mute-stream@1.0.0:
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -3473,10 +3550,18 @@ packages:
once@1.4.0:
resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
open@8.4.2:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
ora@4.1.1:
resolution: {integrity: sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==}
engines: {node: '>=8'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
@@ -3827,6 +3912,10 @@ packages:
resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==}
engines: {node: '>=14.16'}
restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
hasBin: true
@@ -3982,6 +4071,14 @@ packages:
resolution: {integrity: sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==}
engines: {node: '>=16'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
sweet-scroll@4.0.0:
resolution: {integrity: sha512-mR6fRsAQANtm3zpzhUE73KAOt2aT4ZsWzNSggiEsSqdO6Zh4gM7ioJG81EngrZEl0XAc3ZvzEfhxggOoEBc8jA==}
@@ -4174,6 +4271,9 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
wcwidth@1.0.1:
resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
@@ -4398,6 +4498,8 @@ snapshots:
transitivePeerDependencies:
- encoding
'@apiglobal/typedrequest-interfaces@1.0.20': {}
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -5118,6 +5220,24 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdocker@2.2.4':
dependencies:
'@push.rocks/lik': 6.4.0
'@push.rocks/projectinfo': 5.1.0
'@push.rocks/smartcli': 4.0.20
'@push.rocks/smartconfig': 6.1.0
'@push.rocks/smartfs': 1.5.0
'@push.rocks/smartinteract': 2.0.16
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartlog-destination-local': 9.0.2
'@push.rocks/smartlog-source-ora': 1.0.9
'@push.rocks/smartshell': 3.3.8
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@git.zone/tspublish@1.11.5':
dependencies:
'@push.rocks/consolecolor': 2.0.3
@@ -5163,7 +5283,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
@@ -5966,7 +6086,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 7.9.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
@@ -6325,6 +6445,11 @@ snapshots:
'@api.global/typedrequest-interfaces': 2.0.2
'@tsclass/tsclass': 4.4.4
'@push.rocks/smartlog-source-ora@1.0.9':
dependencies:
'@pushrocks/smartlog-interfaces': 2.0.23
ora: 4.1.1
'@push.rocks/smartlog@3.2.2':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
@@ -6433,7 +6558,7 @@ snapshots:
dependencies:
handlebars: 4.7.9
'@push.rocks/smartnetwork@4.6.0':
'@push.rocks/smartnetwork@4.7.0':
dependencies:
'@push.rocks/smartdns': 7.9.0
'@push.rocks/smartrust': 1.3.2
@@ -6499,7 +6624,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfs': 1.5.0
'@push.rocks/smartjimp': 1.2.0
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
@@ -6791,6 +6916,10 @@ snapshots:
fast-json-stable-stringify: 2.1.0
lodash.clonedeep: 4.5.0
'@pushrocks/smartlog-interfaces@2.0.23':
dependencies:
'@apiglobal/typedrequest-interfaces': 1.0.20
'@pushrocks/smartpromise@3.1.10': {}
'@pushrocks/smartpromise@4.0.2': {}
@@ -6935,7 +7064,7 @@ snapshots:
- supports-color
- vue
'@serve.zone/interfaces@5.4.3':
'@serve.zone/interfaces@5.4.6':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2
@@ -7642,6 +7771,10 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-styles@3.2.1:
dependencies:
color-convert: 1.9.3
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
@@ -7805,6 +7938,17 @@ snapshots:
ccount@2.0.1: {}
chalk@2.4.2:
dependencies:
ansi-styles: 3.2.1
escape-string-regexp: 1.0.5
supports-color: 5.5.0
chalk@3.0.0:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
@@ -7827,6 +7971,12 @@ snapshots:
dependencies:
source-map: 0.6.1
cli-cursor@3.1.0:
dependencies:
restore-cursor: 3.1.0
cli-spinners@2.9.2: {}
cli-width@4.1.0: {}
cliui@6.0.0:
@@ -7841,6 +7991,8 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clone@1.0.4: {}
cloudflare@5.2.0:
dependencies:
'@types/node': 18.19.130
@@ -7853,10 +8005,16 @@ snapshots:
transitivePeerDependencies:
- encoding
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.3: {}
color-name@1.1.4: {}
combined-stream@1.0.8:
@@ -7923,6 +8081,10 @@ snapshots:
deepmerge@4.3.1: {}
defaults@1.0.4:
dependencies:
clone: 1.0.4
defer-to-connect@2.0.1: {}
define-data-property@1.1.4:
@@ -8058,6 +8220,8 @@ snapshots:
escalade@3.2.0: {}
escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
@@ -8326,6 +8490,10 @@ snapshots:
webidl-conversions: 7.0.0
whatwg-mimetype: 3.0.0
has-flag@3.0.0: {}
has-flag@4.0.0: {}
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
@@ -8475,6 +8643,8 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
is-interactive@1.0.0: {}
is-nan@1.3.2:
dependencies:
call-bind: 1.0.8
@@ -8659,6 +8829,10 @@ snapshots:
lodash.once@4.1.1: {}
log-symbols@3.0.0:
dependencies:
chalk: 2.4.2
longest-streak@3.1.0: {}
lower-case@1.1.4: {}
@@ -9058,6 +9232,8 @@ snapshots:
mime@4.1.0: {}
mimic-fn@2.1.0: {}
mimic-response@3.1.0: {}
mimic-response@4.0.0: {}
@@ -9161,6 +9337,8 @@ snapshots:
ms@2.1.3: {}
mute-stream@0.0.8: {}
mute-stream@1.0.0: {}
nanoid@4.0.2: {}
@@ -9209,12 +9387,27 @@ snapshots:
dependencies:
wrappy: 1.0.2
onetime@5.1.2:
dependencies:
mimic-fn: 2.1.0
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
ora@4.1.1:
dependencies:
chalk: 3.0.0
cli-cursor: 3.1.0
cli-spinners: 2.9.2
is-interactive: 1.0.0
log-symbols: 3.0.0
mute-stream: 0.0.8
strip-ansi: 6.0.1
wcwidth: 1.0.1
orderedmap@2.1.1: {}
os-tmpdir@1.0.2: {}
@@ -9639,6 +9832,11 @@ snapshots:
dependencies:
lowercase-keys: 3.0.0
restore-cursor@3.1.0:
dependencies:
onetime: 5.1.2
signal-exit: 3.0.7
rimraf@3.0.2:
dependencies:
glob: 7.2.3
@@ -9850,6 +10048,14 @@ snapshots:
'@tokenizer/token': 0.3.0
peek-readable: 5.4.2
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
sweet-scroll@4.0.0: {}
symbol-tree@3.2.4: {}
@@ -10054,6 +10260,10 @@ snapshots:
w3c-keyname@2.2.8: {}
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4
web-streams-polyfill@4.0.0-beta.3: {}
webdriver-bidi-protocol@0.4.1: {}
+155
View File
@@ -0,0 +1,155 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
import { AcmeCertDoc, DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-cert-api-token-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const makeApiTokenManager = (scopes: TScope[]) => {
const token = {
id: 'token-1',
name: 'certificate-test-token',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope),
};
};
const setupHandler = (scopes: TScope[]) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = {
typedrouter,
adminHandler: {
adminIdentityGuard: {
exec: async () => false,
},
},
dcRouterRef: {
apiTokenManager: makeApiTokenManager(scopes),
certificateStatusMap: new Map(),
smartProxy: {
routeManager: { getRoutes: () => [] },
},
certProvisionScheduler: null,
},
};
new CertificateHandler(opsServerRef);
return { typedrouter, opsServerRef };
};
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const testDbPromise = createTestDb();
tap.test('CertificateHandler allows API-token export with certificates:read', async () => {
await testDbPromise;
const certDoc = new AcmeCertDoc();
certDoc.id = 'cert-1';
certDoc.domainName = 'example.com';
certDoc.created = 1;
certDoc.validUntil = 2;
certDoc.privateKey = '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----';
certDoc.publicKey = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----';
certDoc.csr = '';
await certDoc.save();
const { typedrouter } = setupHandler(['certificates:read']);
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
apiToken: 'valid-token',
domain: 'example.com',
});
expect(result.error).toBeUndefined();
expect(result.response.success).toEqual(true);
expect(result.response.cert.domainName).toEqual('example.com');
expect(result.response.cert.privateKey).toContain('BEGIN PRIVATE KEY');
expect(result.response.cert.publicKey).toContain('BEGIN CERTIFICATE');
});
tap.test('CertificateHandler rejects API-token export without certificates:read', async () => {
const { typedrouter } = setupHandler(['certificates:write']);
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
apiToken: 'valid-token',
domain: 'example.com',
});
expect(result.error?.text).toEqual('insufficient scope');
});
tap.test('CertificateHandler allows API-token import with certificates:write', async () => {
await testDbPromise;
const { typedrouter, opsServerRef } = setupHandler(['certificates:write']);
const result = await fireTypedRequest(typedrouter, 'importCertificate', {
apiToken: 'valid-token',
cert: {
id: 'cert-2',
domainName: 'imported.example.com',
created: 3,
validUntil: 4,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
},
});
expect(result.error).toBeUndefined();
expect(result.response.success).toEqual(true);
expect((await AcmeCertDoc.findByDomain('imported.example.com'))?.id).toEqual('cert-2');
expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid');
});
tap.test('cleanup test db', async () => {
const testDb = await testDbPromise;
await testDb.cleanup();
});
export default tap.start();
+129
View File
@@ -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();
+175
View File
@@ -0,0 +1,175 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { WorkAppMailManager } from '../ts/email/classes.workapp-mail-manager.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
class MemoryStorageManager {
public store = new Map<string, string>();
public async get(key: string): Promise<string | null> {
return this.store.get(key) || null;
}
public async set(key: string, value: string): Promise<void> {
this.store.set(key, value);
}
}
const createDcRouterStub = () => {
const storageManager = new MemoryStorageManager();
const emailConfig: IUnifiedEmailServerOptions = {
hostname: 'mail.example.com',
ports: [25, 587, 465],
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns',
},
],
routes: [
{
name: 'operator-route',
match: { recipients: 'ops@example.com' },
action: { type: 'reject', reject: { code: 550, message: 'not here' } },
},
],
auth: {
users: [{ username: 'operator', password: 'secret' }],
},
};
const dcRouterRef: any = {
storageManager,
options: { emailConfig },
emailServer: {
updateOptions: (patch: Partial<IUnifiedEmailServerOptions>) => {
dcRouterRef.options.emailConfig = {
...dcRouterRef.options.emailConfig,
...patch,
};
},
},
updateEmailRoutes: async (routes: IUnifiedEmailServerOptions['routes']) => {
dcRouterRef.options.emailConfig.routes = routes;
},
};
return { dcRouterRef, storageManager };
};
tap.test('WorkAppMailManager syncs SMTP identity and inbound smartmta route', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
const createResult = await manager.syncMailIdentity({
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
localPart: 'Hello',
domain: 'Example.com',
inbound: {
enabled: true,
targetHost: '10.0.0.2',
targetPort: 2525,
},
}, 'tester');
expect(createResult.success).toEqual(true);
expect(createResult.action).toEqual('created');
expect(createResult.identity?.address).toEqual('hello@example.com');
expect(createResult.identity?.smtp.username.startsWith('workapp-')).toEqual(true);
expect((createResult.identity as any).smtpPassword).toBeUndefined();
expect(createResult.smtpCredentials?.password.length).toBeGreaterThan(20);
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
expect(generatedRoute.match.recipients).toEqual('hello@example.com');
expect(generatedRoute.action.forward.host).toEqual('10.0.0.2');
expect(generatedRoute.action.forward.port).toEqual(2525);
expect(generatedRoute.action.forward.addHeaders['X-Dcrouter-WorkApp-Id']).toEqual('app-1');
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name === 'operator-route')).toEqual(true);
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
expect(generatedUser.password).toEqual(createResult.smtpCredentials?.password);
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
const listResult = await manager.listMailIdentities({ workAppId: 'app-1' });
expect(listResult.length).toEqual(1);
expect(listResult[0].address).toEqual('hello@example.com');
});
tap.test('WorkAppMailManager updates, resets credentials, and deletes identities', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
const ownership = {
workHosterType: 'onebox' as const,
workHosterId: 'box-1',
workAppId: 'app-1',
};
const createResult = await manager.syncMailIdentity({
ownership,
localPart: 'hello',
domain: 'example.com',
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
}, 'tester');
const firstPassword = createResult.smtpCredentials!.password;
const updateResult = await manager.syncMailIdentity({
ownership,
localPart: 'hello',
domain: 'example.com',
inbound: { enabled: true, targetHost: '10.0.0.3', targetPort: 2526 },
}, 'tester');
expect(updateResult.action).toEqual('updated');
expect(updateResult.smtpCredentials).toBeUndefined();
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
expect(generatedUser.password).toEqual(firstPassword);
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
expect(generatedRoute.action.forward.host).toEqual('10.0.0.3');
const resetResult = await manager.syncMailIdentity({
ownership,
localPart: 'hello',
domain: 'example.com',
resetSmtpPassword: true,
}, 'tester');
expect(resetResult.smtpCredentials?.password !== firstPassword).toEqual(true);
const deleteResult = await manager.syncMailIdentity({
ownership,
localPart: 'hello',
domain: 'example.com',
delete: true,
}, 'tester');
expect(deleteResult.action).toEqual('deleted');
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username.startsWith('workapp-'))).toEqual(false);
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
});
tap.test('WorkAppMailManager applies persisted identities to startup email config', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
await manager.syncMailIdentity({
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
localPart: 'hello',
domain: 'example.com',
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
}, 'tester');
const baseStartupConfig: IUnifiedEmailServerOptions = {
hostname: 'mail.example.com',
ports: [25],
domains: [{ domain: 'example.com', dnsMode: 'external-dns' }],
routes: [],
};
const startupConfig = await manager.applyStoredIdentitiesToEmailConfig(baseStartupConfig);
expect(startupConfig.routes.some((route) => route.name.startsWith('workapp-mail-'))).toEqual(true);
expect(startupConfig.auth?.users?.some((user) => user.username.startsWith('workapp-'))).toEqual(true);
});
export default tap.start();
+382
View File
@@ -0,0 +1,382 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { WorkHosterHandler } from '../ts/opsserver/handlers/workhoster.handler.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeApiTokenManager = (scopes: TScope[]) => {
const token = {
id: 'token-1',
name: 'workhoster-test-token',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope),
};
};
const makeRouteConfigManager = () => {
const routes = new Map<string, interfaces.data.IRoute>();
let nextRouteNumber = 1;
return {
routes,
manager: {
findApiRouteByExternalKey: (externalKey: string) => {
return Array.from(routes.values()).find((route) =>
route.origin === 'api' && route.metadata?.externalKey === externalKey,
);
},
createRoute: async (
route: interfaces.data.IDcRouterRouteConfig,
createdBy: string,
enabled = true,
metadata?: interfaces.data.IRouteMetadata,
) => {
const id = `route-${nextRouteNumber++}`;
routes.set(id, {
id,
route,
enabled,
createdBy,
createdAt: Date.now(),
updatedAt: Date.now(),
origin: 'api',
metadata,
});
return id;
},
updateRoute: async (
id: string,
patch: {
route?: Partial<interfaces.data.IDcRouterRouteConfig>;
enabled?: boolean;
metadata?: Partial<interfaces.data.IRouteMetadata>;
},
) => {
const storedRoute = routes.get(id);
if (!storedRoute) return { success: false, message: 'Route not found' };
if (patch.route) {
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
}
if (patch.enabled !== undefined) {
storedRoute.enabled = patch.enabled;
}
if (patch.metadata) {
storedRoute.metadata = { ...storedRoute.metadata, ...patch.metadata };
}
storedRoute.updatedAt = Date.now();
return { success: true };
},
deleteRoute: async (id: string) => {
const deleted = routes.delete(id);
return deleted ? { success: true } : { success: false, message: 'Route not found' };
},
},
};
};
const setupHandler = (options: {
scopes: TScope[];
dcRouterRef?: Record<string, any>;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = {
typedrouter,
adminHandler: {
adminIdentityGuard: {
exec: async () => false,
},
},
dcRouterRef: {
options: {},
apiTokenManager: makeApiTokenManager(options.scopes),
...options.dcRouterRef,
},
};
new WorkHosterHandler(opsServerRef);
return { typedrouter, opsServerRef };
};
tap.test('WorkHosterHandler exposes capabilities and managed domains with workhosters:read', async () => {
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {
remoteIngressConfig: { enabled: true },
dnsScopes: ['example.com'],
http3: { enabled: false },
},
routeConfigManager: {},
smartProxy: {},
emailDomainManager: {},
emailServer: {},
dnsManager: {
listDomains: async () => [
{ id: 'domain-1', name: 'example.com', source: 'dcrouter', authoritative: true },
{ id: 'domain-2', name: 'provider.example', source: 'provider', providerId: 'cloudflare-1', authoritative: false },
],
toPublicDomain: (domainDoc: any) => ({
...domainDoc,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
}),
},
},
});
const capabilitiesResult = await fireTypedRequest(typedrouter, 'getGatewayCapabilities', {
apiToken: 'valid-token',
});
expect(capabilitiesResult.error).toBeUndefined();
expect(capabilitiesResult.response.capabilities.routes.idempotentSync).toEqual(true);
expect(capabilitiesResult.response.capabilities.domains.read).toEqual(true);
expect(capabilitiesResult.response.capabilities.certificates.export).toEqual(true);
expect(capabilitiesResult.response.capabilities.email.inbound).toEqual(true);
expect(capabilitiesResult.response.capabilities.remoteIngress.enabled).toEqual(true);
expect(capabilitiesResult.response.capabilities.dns.authoritative).toEqual(true);
expect(capabilitiesResult.response.capabilities.http3.enabled).toEqual(false);
const domainsResult = await fireTypedRequest(typedrouter, 'getWorkHosterDomains', {
apiToken: 'valid-token',
});
expect(domainsResult.error).toBeUndefined();
expect(domainsResult.response.domains.length).toEqual(2);
expect(domainsResult.response.domains[0].capabilities.canCreateSubdomains).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canManageDnsRecords).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canIssueCertificates).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canHostEmail).toEqual(true);
});
tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:write', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['workhosters:write'],
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const ownership: interfaces.data.IWorkAppRouteOwnership = {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
hostname: 'app.example.com',
};
const createResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
route: {
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.2', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(createResult.error).toBeUndefined();
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(1);
const createdRoute = routeConfig.routes.get('route-1')!;
expect(createdRoute.createdBy).toEqual('token-user');
expect(createdRoute.route.name?.startsWith('workapp-onebox-box-1-app-1-app-example-com')).toEqual(true);
expect(createdRoute.metadata).toEqual({
ownerType: 'workhoster',
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
});
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
enabled: false,
route: {
name: 'updated-workapp-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.3', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(updateResult.error).toBeUndefined();
expect(updateResult.response).toEqual({ success: true, action: 'updated', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(1);
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
delete: true,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response).toEqual({ success: true, action: 'deleted', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(0);
const unchangedResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
delete: true,
});
expect(unchangedResult.error).toBeUndefined();
expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' });
});
tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const result = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
hostname: 'app.example.com',
},
delete: true,
});
expect(result.error?.text).toEqual('insufficient scope');
expect(routeConfig.routes.size).toEqual(0);
});
tap.test('WorkHosterHandler exposes and syncs WorkApp mail identities', async () => {
const syncedRequests: Array<{ data: any; userId: string }> = [];
const identity: interfaces.data.IWorkAppMailIdentity = {
id: 'mail-1',
externalKey: 'onebox:box-1:app-1:hello@example.com',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
inbound: {
enabled: true,
targetHost: '10.0.0.2',
targetPort: 2525,
},
smtp: {
enabled: true,
username: 'workapp-user',
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['workhosters:read', 'workhosters:write'],
dcRouterRef: {
options: {},
workAppMailManager: {
listMailIdentities: async (filter: any) => filter.workAppId === 'app-1' ? [identity] : [],
syncMailIdentity: async (data: any, userId: string) => {
syncedRequests.push({ data, userId });
return {
success: true,
action: 'created',
identity,
smtpCredentials: {
username: 'workapp-user',
password: 'generated-password',
},
};
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'getWorkAppMailIdentities', {
apiToken: 'valid-token',
ownership: { workAppId: 'app-1' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.identities).toEqual([identity]);
const syncResult = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
apiToken: 'valid-token',
ownership: identity.ownership,
localPart: 'hello',
domain: 'example.com',
inbound: identity.inbound,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncResult.response.smtpCredentials.password).toEqual('generated-password');
expect(syncedRequests[0].userId).toEqual('token-user');
});
tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write', async () => {
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {},
workAppMailManager: {
syncMailIdentity: async () => ({ success: true }),
},
},
});
const result = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
apiToken: 'valid-token',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
localPart: 'hello',
domain: 'example.com',
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
node --input-type=module <<'NODE'
import fs from 'node:fs';
const readJson = (path) => JSON.parse(fs.readFileSync(path, 'utf8'));
const checks = {
packageVersion: readJson('/app/package.json').version,
interfacesVersion: readJson('/app/node_modules/@serve.zone/interfaces/package.json').version,
remoteingressVersion: readJson('/app/node_modules/@serve.zone/remoteingress/package.json').version,
hasCli: fs.existsSync('/app/cli.js'),
hasWebBundle: fs.existsSync('/app/dist_serve/bundle.js'),
};
await import('/app/dist_ts/index.js');
if (checks.packageVersion !== '13.25.0') {
throw new Error(`Unexpected dcrouter package version ${checks.packageVersion}`);
}
if (checks.interfacesVersion !== '5.4.6') {
throw new Error(`Unexpected interfaces version ${checks.interfacesVersion}`);
}
if (checks.remoteingressVersion !== '4.17.1') {
throw new Error(`Unexpected remoteingress version ${checks.remoteingressVersion}`);
}
if (!checks.hasCli) {
throw new Error('Missing cli.js');
}
if (!checks.hasWebBundle) {
throw new Error('Missing web bundle');
}
console.log(JSON.stringify(checks));
NODE
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.24.0',
version: '13.25.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+5 -3
View File
@@ -31,7 +31,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyMana
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js';
import { AcmeConfigManager } from './acme/manager.acme-config.js';
import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
import type { IRoute } from '../ts_interfaces/data/route-management.js';
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
@@ -285,6 +285,7 @@ export class DcRouter {
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
public acmeConfigManager?: AcmeConfigManager;
public emailDomainManager?: EmailDomainManager;
public workAppMailManager: WorkAppMailManager;
public securityPolicyManager?: SecurityPolicyManager;
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
@@ -339,6 +340,7 @@ export class DcRouter {
this.storageManager = new SmartMtaStorageManager(
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
);
this.workAppMailManager = new WorkAppMailManager(this);
// Initialize service manager and register all services
this.serviceManager = new plugins.taskbuffer.ServiceManager({
@@ -1630,7 +1632,7 @@ export class DcRouter {
}
// Create config with mapped ports
const emailConfig: IUnifiedEmailServerOptions = {
const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
...this.options.emailConfig,
domains: transformedDomains,
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
@@ -1640,7 +1642,7 @@ export class DcRouter {
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
...this.options.emailConfig.queue,
},
};
});
// Create unified email server
this.emailServer = new UnifiedEmailServer(this, emailConfig);
+24
View File
@@ -256,6 +256,15 @@ export class RouteConfigManager {
return this.updateRoute(id, { enabled });
}
public findApiRouteByExternalKey(externalKey: string): IRoute | undefined {
for (const route of this.routes.values()) {
if (route.origin === 'api' && route.metadata?.externalKey === externalKey) {
return route;
}
}
return undefined;
}
// =========================================================================
// Private: seed routes from constructor config
// =========================================================================
@@ -443,6 +452,15 @@ export class RouteConfigManager {
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
? metadata.lastResolvedAt
: undefined,
ownerType: metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
? metadata.ownerType
: undefined,
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
? metadata.workHosterType
: undefined,
workHosterId: normalizeString(metadata.workHosterId),
workAppId: normalizeString(metadata.workAppId),
externalKey: normalizeString(metadata.externalKey),
};
if (!normalized.sourceProfileRef) {
@@ -454,6 +472,12 @@ export class RouteConfigManager {
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (normalized.ownerType !== 'workhoster') {
normalized.workHosterType = undefined;
normalized.workHosterId = undefined;
normalized.workAppId = undefined;
normalized.externalKey = undefined;
}
if (Object.values(normalized).every((value) => value === undefined)) {
return undefined;
@@ -25,6 +25,9 @@ export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntell
@plugins.smartdata.svDb()
public networkRange: string | null = null;
@plugins.smartdata.svDb()
public networkCidrs: string[] | null = null;
@plugins.smartdata.svDb()
public abuseContact: string | null = null;
+33 -1
View File
@@ -57,6 +57,31 @@ export class EmailDomainManager {
return doc ? this.docToInterface(doc) : null;
}
public async getByDomain(domainName: string): Promise<IEmailDomain | null> {
const doc = await EmailDomainDoc.findByDomain(domainName);
return doc ? this.docToInterface(doc) : null;
}
public async ensureEmailDomainForDomainName(domainName: string): Promise<IEmailDomain | null> {
const normalizedDomain = domainName.trim().toLowerCase();
const existing = await this.getByDomain(normalizedDomain);
if (existing) return existing;
if (this.isDomainAlreadyConfigured(normalizedDomain)) return null;
const linkedDomain = await this.findLinkedDnsDomain(normalizedDomain);
if (!linkedDomain) {
throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
}
const subdomain = normalizedDomain === linkedDomain.name
? undefined
: normalizedDomain.slice(0, -(linkedDomain.name.length + 1));
return await this.createEmailDomain({
linkedDomainId: linkedDomain.id,
subdomain,
});
}
public async createEmailDomain(opts: {
linkedDomainId: string;
subdomain?: string;
@@ -351,6 +376,13 @@ export class EmailDomainManager {
return configuredDomains.includes(domainName.toLowerCase());
}
private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
const domains = await DomainDoc.findAll();
return domains
.filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
.sort((a, b) => b.name.length - a.name.length)[0] || null;
}
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
const docs = await EmailDomainDoc.findAll();
const managedConfigs: IEmailDomainConfig[] = [];
@@ -378,7 +410,7 @@ export class EmailDomainManager {
return managedConfigs;
}
private async syncManagedDomainsToRuntime(): Promise<void> {
public async syncManagedDomainsToRuntime(): Promise<void> {
if (!this.dcRouter.options?.emailConfig) {
return;
}
+343
View File
@@ -0,0 +1,343 @@
import type {
IEmailRoute,
IUnifiedEmailServerOptions,
} from '@push.rocks/smartmta';
import * as plugins from '../plugins.js';
import type * as interfaces from '../../ts_interfaces/index.js';
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
smtpPassword: string;
}
interface IStoredWorkAppMailState {
version: 1;
identities: IStoredWorkAppMailIdentity[];
}
export class WorkAppMailManager {
private readonly storageKey = '/workhosters/mail-identities.json';
constructor(private dcRouterRef: any) {}
public async listMailIdentities(
ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
): Promise<interfaces.data.IWorkAppMailIdentity[]> {
const identities = await this.readStoredIdentities();
return identities
.filter((identity) => this.matchesOwnership(identity.ownership, ownership))
.map((identity) => this.toPublicIdentity(identity));
}
public async syncMailIdentity(
request: TSyncRequest,
createdBy: string,
): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
if (!this.dcRouterRef.options.emailConfig) {
return { success: false, message: 'Email server is not configured' };
}
const ownership = this.normalizeOwnership(request.ownership);
const domain = this.normalizeDomain(request.domain);
const localPart = this.normalizeLocalPart(request.localPart);
const address = `${localPart}@${domain}`;
const externalKey = this.buildExternalKey(ownership, address);
const identities = await this.readStoredIdentities();
const existingIndex = identities.findIndex((identity) => identity.externalKey === externalKey);
if (request.delete) {
if (existingIndex < 0) {
return { success: true, action: 'unchanged' };
}
const [deletedIdentity] = identities.splice(existingIndex, 1);
await this.writeStoredIdentities(identities);
await this.applyStoredIdentitiesToRuntime(identities);
return {
success: true,
action: 'deleted',
identity: this.toPublicIdentity(deletedIdentity),
};
}
await this.ensureEmailDomainConfigured(domain);
const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
const now = Date.now();
const smtpPassword = existingIdentity && !request.resetSmtpPassword
? existingIdentity.smtpPassword
: this.generateSmtpPassword();
const identity: IStoredWorkAppMailIdentity = {
id: existingIdentity?.id || plugins.smartunique.shortId(),
externalKey,
ownership,
address,
localPart,
domain,
enabled: request.enabled ?? existingIdentity?.enabled ?? true,
displayName: request.displayName ?? existingIdentity?.displayName,
inbound: this.normalizeInboundRoute(request.inbound ?? existingIdentity?.inbound),
smtp: {
enabled: request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true,
username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
},
createdAt: existingIdentity?.createdAt || now,
updatedAt: now,
createdBy: existingIdentity?.createdBy || createdBy,
smtpPassword,
};
if (existingIndex >= 0) {
identities[existingIndex] = identity;
} else {
identities.push(identity);
}
await this.writeStoredIdentities(identities);
await this.applyStoredIdentitiesToRuntime(identities);
const response: interfaces.data.IWorkAppMailIdentitySyncResult = {
success: true,
action: existingIndex >= 0 ? 'updated' : 'created',
identity: this.toPublicIdentity(identity),
};
if (existingIndex < 0 || request.resetSmtpPassword) {
response.smtpCredentials = this.buildSmtpCredentials(identity);
}
return response;
}
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
emailConfig: TConfig,
): Promise<TConfig> {
const identities = await this.readStoredIdentities();
return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities);
}
public async applyStoredIdentitiesToRuntime(
identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
): Promise<void> {
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
if (!emailConfig) return;
const nextConfig = this.mergeIdentitiesIntoEmailConfig(
emailConfig,
identities || await this.readStoredIdentities(),
);
this.dcRouterRef.options.emailConfig = nextConfig;
if (this.dcRouterRef.emailServer) {
this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
}
}
private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
const storedData = await this.dcRouterRef.storageManager.get(this.storageKey);
if (!storedData) return [];
const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
return Array.isArray(parsed) ? parsed : parsed.identities || [];
}
private async writeStoredIdentities(identities: IStoredWorkAppMailIdentity[]): Promise<void> {
const state: IStoredWorkAppMailState = {
version: 1,
identities,
};
await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
}
private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
emailConfig: TConfig,
identities: IStoredWorkAppMailIdentity[],
): TConfig {
const generatedRoutes = identities
.filter((identity) => identity.enabled && identity.inbound?.enabled)
.map((identity) => this.buildInboundRoute(identity));
const configuredRoutes = (emailConfig.routes || [])
.filter((route) => !this.isManagedMailRouteName(route.name));
const generatedUsers = identities
.filter((identity) => identity.enabled && identity.smtp.enabled)
.map((identity) => ({
username: identity.smtp.username,
password: identity.smtpPassword,
}));
const configuredUsers = (emailConfig.auth?.users || [])
.filter((user) => !this.isManagedSmtpUsername(user.username));
return {
...emailConfig,
routes: [...configuredRoutes, ...generatedRoutes],
auth: {
...(emailConfig.auth || {}),
users: [...configuredUsers, ...generatedUsers],
},
};
}
private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
const inbound = identity.inbound!;
return {
name: this.buildRouteName(identity.externalKey),
priority: 1000,
match: {
recipients: identity.address,
},
action: {
type: 'forward',
forward: {
host: inbound.targetHost,
port: inbound.targetPort,
preserveHeaders: inbound.preserveHeaders ?? true,
addHeaders: {
'X-Dcrouter-WorkHoster-Type': identity.ownership.workHosterType,
'X-Dcrouter-WorkHoster-Id': identity.ownership.workHosterId,
'X-Dcrouter-WorkApp-Id': identity.ownership.workAppId,
...(inbound.addHeaders || {}),
},
},
},
};
}
private async ensureEmailDomainConfigured(domain: string): Promise<void> {
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
return;
}
const emailDomainManager = this.dcRouterRef.emailDomainManager;
if (!emailDomainManager) {
throw new Error(`Email domain is not configured: ${domain}`);
}
if (await emailDomainManager.getByDomain(domain)) {
await emailDomainManager.syncManagedDomainsToRuntime();
return;
}
await emailDomainManager.ensureEmailDomainForDomainName(domain);
}
private normalizeOwnership(
ownership: interfaces.data.IWorkAppMailOwnership,
): interfaces.data.IWorkAppMailOwnership {
const workHosterType = ownership.workHosterType;
const workHosterId = ownership.workHosterId?.trim();
const workAppId = ownership.workAppId?.trim();
if (!['onebox', 'cloudly', 'custom'].includes(workHosterType)) {
throw new Error(`Invalid WorkHoster type: ${workHosterType}`);
}
if (!workHosterId) throw new Error('workHosterId is required');
if (!workAppId) throw new Error('workAppId is required');
return { workHosterType, workHosterId, workAppId };
}
private normalizeDomain(domain: string): string {
const normalized = domain?.trim().toLowerCase();
if (!normalized || normalized.includes('@') || !normalized.includes('.')) {
throw new Error(`Invalid email domain: ${domain}`);
}
return normalized;
}
private normalizeLocalPart(localPart: string): string {
const normalized = localPart?.trim().toLowerCase();
if (!normalized || normalized.includes('@') || /\s/.test(normalized)) {
throw new Error(`Invalid email local part: ${localPart}`);
}
return normalized;
}
private normalizeInboundRoute(
inbound?: interfaces.data.IWorkAppMailInboundRoute,
): interfaces.data.IWorkAppMailInboundRoute | undefined {
if (!inbound) return undefined;
if (!inbound.enabled) {
return { ...inbound, enabled: false };
}
const targetHost = inbound.targetHost?.trim();
const targetPort = Number(inbound.targetPort);
if (!targetHost) throw new Error('inbound.targetHost is required when inbound routing is enabled');
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
throw new Error(`Invalid inbound.targetPort: ${inbound.targetPort}`);
}
return {
...inbound,
targetHost,
targetPort,
};
}
private matchesOwnership(
ownership: interfaces.data.IWorkAppMailOwnership,
filter?: Partial<interfaces.data.IWorkAppMailOwnership>,
): boolean {
if (!filter) return true;
if (filter.workHosterType && filter.workHosterType !== ownership.workHosterType) return false;
if (filter.workHosterId && filter.workHosterId !== ownership.workHosterId) return false;
if (filter.workAppId && filter.workAppId !== ownership.workAppId) return false;
return true;
}
private buildExternalKey(
ownership: interfaces.data.IWorkAppMailOwnership,
address: string,
): string {
return [
ownership.workHosterType,
ownership.workHosterId,
ownership.workAppId,
address,
].join(':');
}
private buildSmtpUsername(externalKey: string): string {
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
}
private buildRouteName(externalKey: string): string {
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
}
private hashExternalKey(externalKey: string): string {
return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
}
private generateSmtpPassword(): string {
return plugins.crypto.randomBytes(24).toString('base64url');
}
private isManagedMailRouteName(routeName: string): boolean {
return routeName.startsWith('workapp-mail-');
}
private isManagedSmtpUsername(username: string): boolean {
return username.startsWith('workapp-');
}
private buildSmtpCredentials(
identity: IStoredWorkAppMailIdentity,
): interfaces.data.IWorkAppMailCredentials {
return {
username: identity.smtp.username,
password: identity.smtpPassword,
host: this.dcRouterRef.options.emailConfig?.outbound?.hostname
|| this.dcRouterRef.options.emailConfig?.hostname,
ports: {
smtp: this.dcRouterRef.options.emailConfig?.ports?.includes(25) ? 25 : undefined,
submission: this.dcRouterRef.options.emailConfig?.ports?.includes(587) ? 587 : undefined,
smtps: this.dcRouterRef.options.emailConfig?.ports?.includes(465) ? 465 : undefined,
},
};
}
private toPublicIdentity(
identity: IStoredWorkAppMailIdentity,
): interfaces.data.IWorkAppMailIdentity {
const { smtpPassword, ...publicIdentity } = identity;
return publicIdentity;
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './classes.email-domain.manager.js';
export * from './classes.smartmta-storage-manager.js';
export * from './classes.workapp-mail-manager.js';
export * from './email-dns-records.js';
+2
View File
@@ -38,6 +38,7 @@ export class OpsServer {
private dnsRecordHandler!: handlers.DnsRecordHandler;
private acmeConfigHandler!: handlers.AcmeConfigHandler;
private emailDomainHandler!: handlers.EmailDomainHandler;
private workHosterHandler!: handlers.WorkHosterHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -106,6 +107,7 @@ export class OpsServer {
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
this.workHosterHandler = new handlers.WorkHosterHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}
+45 -12
View File
@@ -26,21 +26,51 @@ export function deriveCertDomainName(domain: string): string | undefined {
}
export class CertificateHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter?.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
const router = this.typedrouter;
// Get Certificate Overview
viewRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
'getCertificateOverview',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:read');
const certificates = await this.buildCertificateOverview();
const summary = this.buildSummary(certificates);
return { certificates, summary };
@@ -48,53 +78,56 @@ export class CertificateHandler {
)
);
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Legacy route-based reprovision (backward compat)
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:write');
return this.reprovisionCertificateByRoute(dataArg.routeName);
}
)
);
// Domain-based reprovision (preferred)
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:write');
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
}
)
);
// Delete certificate
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:write');
return this.deleteCertificate(dataArg.domain);
}
)
);
// Export certificate
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:read');
return this.exportCertificate(dataArg.domain);
}
)
);
// Import certificate
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:write');
return this.importCertificate(dataArg.cert);
}
)
+1
View File
@@ -19,3 +19,4 @@ export * from './domain.handler.js';
export * from './dns-record.handler.js';
export * from './acme-config.handler.js';
export * from './email-domain.handler.js';
export * from './workhoster.handler.js';
+219
View File
@@ -0,0 +1,219 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class WorkHosterHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
'getGatewayCapabilities',
async (dataArg) => {
await this.requireAuth(dataArg, 'workhosters:read');
return { capabilities: this.getGatewayCapabilities() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkHosterDomains>(
'getWorkHosterDomains',
async (dataArg) => {
await this.requireAuth(dataArg, 'workhosters:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { domains: [] };
const docs = await dnsManager.listDomains();
const domains = docs.map((domainDoc) => {
const domain = dnsManager.toPublicDomain(domainDoc);
const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId);
return {
...domain,
capabilities: {
canCreateSubdomains: canManageDnsRecords,
canManageDnsRecords,
canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy),
canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager),
},
} satisfies interfaces.data.IWorkHosterDomain;
});
return { domains };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppRoute>(
'syncWorkAppRoute',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'workhosters:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const externalKey = this.buildExternalKey(dataArg.ownership);
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
if (dataArg.delete) {
if (!existingRoute) {
return { success: true, action: 'unchanged' };
}
const result = await manager.deleteRoute(existingRoute.id);
return result.success
? { success: true, action: 'deleted', routeId: existingRoute.id }
: { success: false, message: result.message };
}
if (!dataArg.route) {
return { success: false, message: 'route is required unless delete=true' };
}
const metadata: interfaces.data.IRouteMetadata = {
ownerType: 'workhoster',
workHosterType: dataArg.ownership.workHosterType,
workHosterId: dataArg.ownership.workHosterId,
workAppId: dataArg.ownership.workAppId,
externalKey,
};
const route = this.normalizeWorkAppRoute(dataArg.route, dataArg.ownership, externalKey);
if (existingRoute) {
const result = await manager.updateRoute(existingRoute.id, {
route,
enabled: dataArg.enabled ?? true,
metadata,
});
return result.success
? { success: true, action: 'updated', routeId: existingRoute.id }
: { success: false, message: result.message };
}
const routeId = await manager.createRoute(route, userId, dataArg.enabled ?? true, metadata);
return { success: true, action: 'created', routeId };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkAppMailIdentities>(
'getWorkAppMailIdentities',
async (dataArg) => {
await this.requireAuth(dataArg, 'workhosters:read');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) return { identities: [] };
return { identities: await manager.listMailIdentities(dataArg.ownership) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppMailIdentity>(
'syncWorkAppMailIdentity',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'workhosters:write');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) {
return { success: false, message: 'WorkApp mail manager not initialized' };
}
try {
return await manager.syncMailIdentity(dataArg, userId);
} catch (error) {
return { success: false, message: (error as Error).message };
}
},
),
);
}
private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
const dcRouter = this.opsServerRef.dcRouterRef;
return {
routes: {
read: Boolean(dcRouter.routeConfigManager),
write: Boolean(dcRouter.routeConfigManager),
idempotentSync: Boolean(dcRouter.routeConfigManager),
},
domains: {
read: Boolean(dcRouter.dnsManager),
write: Boolean(dcRouter.dnsManager),
},
certificates: {
read: Boolean(dcRouter.smartProxy),
export: Boolean(dcRouter.smartProxy),
forceRenew: Boolean(dcRouter.smartProxy),
},
email: {
domains: Boolean(dcRouter.emailDomainManager),
inbound: Boolean(dcRouter.emailServer),
outbound: Boolean(dcRouter.emailServer),
},
remoteIngress: {
enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled),
},
dns: {
authoritative: Boolean(dcRouter.options.dnsScopes?.length),
providerManaged: Boolean(dcRouter.dnsManager),
},
http3: {
enabled: dcRouter.options.http3?.enabled !== false,
},
};
}
private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string {
return [
ownership.workHosterType,
ownership.workHosterId,
ownership.workAppId,
ownership.hostname,
].map((part) => part.trim()).join(':');
}
private normalizeWorkAppRoute(
route: interfaces.data.IDcRouterRouteConfig,
ownership: interfaces.data.IWorkAppRouteOwnership,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };
if (!normalizedRoute.name) {
normalizedRoute.name = `workapp-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
}
return normalizedRoute;
}
}
+89 -7
View File
@@ -16,7 +16,7 @@ export interface ISecurityPolicyManagerOptions {
}
export interface IRemoteIngressFirewallSnapshot {
blockedIps?: string[];
blockedIps: string[];
}
export class SecurityPolicyManager {
@@ -122,6 +122,7 @@ export class SecurityPolicyManager {
registrantOrg: doc.registrantOrg,
registrantCountry: doc.registrantCountry,
networkRange: doc.networkRange,
networkCidrs: doc.networkCidrs,
abuseContact: doc.abuseContact,
country: doc.country,
countryCode: doc.countryCode,
@@ -205,16 +206,22 @@ export class SecurityPolicyManager {
}
if (rule.type === 'cidr') {
const cidr = this.normalizeCidr(normalizedValue);
if (cidr) blockedCidrs.add(cidr);
for (const cidr of this.normalizeNetworkEntries(normalizedValue)) {
blockedCidrs.add(cidr);
}
continue;
}
for (const doc of intelligenceDocs) {
if (!this.ruleMatchesIntelligence(rule, doc)) continue;
const cidr = this.normalizeCidr(doc.networkRange || '');
if (cidr) {
const networkEntries = this.normalizeNetworkEntryList([
...(doc.networkCidrs || []),
doc.networkRange,
]);
if (networkEntries.length > 0) {
for (const cidr of networkEntries) {
blockedCidrs.add(cidr);
}
} else if (this.normalizeIp(doc.ipAddress)) {
blockedIps.add(this.normalizeIp(doc.ipAddress)!);
}
@@ -231,13 +238,13 @@ export class SecurityPolicyManager {
return await this.compilePolicy();
}
public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot | undefined> {
public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot> {
const policy = await this.compilePolicy();
const blockedIps = [
...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4),
...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4),
];
return blockedIps.length > 0 ? { blockedIps } : undefined;
return { blockedIps };
}
private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise<boolean> {
@@ -287,6 +294,81 @@ export class SecurityPolicyManager {
return `${ip}/${prefix}`;
}
private normalizeNetworkEntries(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
const cidr = this.normalizeCidr(trimmed);
if (cidr) return [cidr];
const rangeParts = trimmed.split(/\s+-\s+/);
if (rangeParts.length === 2) {
return this.ipv4RangeToCidrs(rangeParts[0], rangeParts[1]);
}
return [];
}
private normalizeNetworkEntryList(values: Array<string | null | undefined>): string[] {
const cidrs = new Set<string>();
for (const value of values) {
if (!value) continue;
for (const entry of value.split(',').map((part) => part.trim()).filter(Boolean)) {
for (const cidr of this.normalizeNetworkEntries(entry)) {
cidrs.add(cidr);
}
}
}
return [...cidrs];
}
private ipv4RangeToCidrs(startIp: string, endIp: string): string[] {
const start = this.ipv4ToBigInt(startIp);
const end = this.ipv4ToBigInt(endIp);
if (start === undefined || end === undefined || start > end) return [];
const cidrs: string[] = [];
let current = start;
while (current <= end) {
let maxBlockSize = current === 0n ? 1n << 32n : current & -current;
const remaining = end - current + 1n;
while (maxBlockSize > remaining) {
maxBlockSize = maxBlockSize / 2n;
}
const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize);
cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`);
current += maxBlockSize;
}
return cidrs;
}
private ipv4ToBigInt(ip: string): bigint | undefined {
const normalized = this.normalizeIp(ip);
if (!normalized || plugins.net.isIP(normalized) !== 4) return undefined;
return normalized
.split('.')
.reduce((sum, part) => (sum * 256n) + BigInt(Number(part)), 0n);
}
private numberToIpv4(value: bigint): string {
return [
Number((value >> 24n) & 255n),
Number((value >> 16n) & 255n),
Number((value >> 8n) & 255n),
Number(value & 255n),
].join('.');
}
private powerOfTwoExponent(value: bigint): number {
let exponent = 0;
let remaining = value;
while (remaining > 1n) {
remaining >>= 1n;
exponent++;
}
return exponent;
}
private isPublicIp(ip: string): boolean {
const family = plugins.net.isIP(ip);
if (family === 4) {
@@ -10,6 +10,7 @@ import { ConfigManager } from './classes.config.js';
import { LogManager } from './classes.logs.js';
import { EmailManager } from './classes.email.js';
import { RadiusManager } from './classes.radius.js';
import { WorkHosterManager } from './classes.workhoster.js';
export interface IDcRouterApiClientOptions {
baseUrl: string;
@@ -31,6 +32,7 @@ export class DcRouterApiClient {
public logs: LogManager;
public emails: EmailManager;
public radius: RadiusManager;
public workHosters: WorkHosterManager;
constructor(options: IDcRouterApiClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
@@ -45,6 +47,7 @@ export class DcRouterApiClient {
this.logs = new LogManager(this);
this.emails = new EmailManager(this);
this.radius = new RadiusManager(this);
this.workHosters = new WorkHosterManager(this);
}
// =====================
+49
View File
@@ -0,0 +1,49 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
export class WorkHosterManager {
constructor(private clientRef: DcRouterApiClient) {}
public async getCapabilities(): Promise<interfaces.data.IGatewayCapabilities> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetGatewayCapabilities>(
'getGatewayCapabilities',
this.clientRef.buildRequestPayload() as any,
);
return response.capabilities;
}
public async getDomains(): Promise<interfaces.data.IWorkHosterDomain[]> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetWorkHosterDomains>(
'getWorkHosterDomains',
this.clientRef.buildRequestPayload() as any,
);
return response.domains;
}
public async syncRoute(options: {
ownership: interfaces.data.IWorkAppRouteOwnership;
route: interfaces.data.IDcRouterRouteConfig;
enabled?: boolean;
}): Promise<interfaces.data.IWorkAppRouteSyncResult> {
return this.clientRef.request<interfaces.requests.IReq_SyncWorkAppRoute>(
'syncWorkAppRoute',
this.clientRef.buildRequestPayload({
ownership: options.ownership,
route: options.route,
enabled: options.enabled,
}) as any,
);
}
public async deleteRoute(
ownership: interfaces.data.IWorkAppRouteOwnership,
): Promise<interfaces.data.IWorkAppRouteSyncResult> {
return this.clientRef.request<interfaces.requests.IReq_SyncWorkAppRoute>(
'syncWorkAppRoute',
this.clientRef.buildRequestPayload({
ownership,
delete: true,
}) as any,
);
}
}
+1
View File
@@ -7,6 +7,7 @@ export { Certificate, CertificateManager, type ICertificateSummary } from './cla
export { ApiToken, ApiTokenBuilder, ApiTokenManager } from './classes.apitoken.js';
export { RemoteIngress, RemoteIngressBuilder, RemoteIngressManager } from './classes.remoteingress.js';
export { Email, EmailManager } from './classes.email.js';
export { WorkHosterManager } from './classes.workhoster.js';
// Read-only managers
export { StatsManager } from './classes.stats.js';
+1
View File
@@ -6,6 +6,7 @@ export * from './target-profile.js';
export * from './vpn.js';
export * from './dns-provider.js';
export * from './domain.js';
export * from './workhoster.js';
export * from './dns-record.js';
export * from './acme-config.js';
export * from './email-domain.js';
+12 -1
View File
@@ -11,6 +11,7 @@ export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
export type TApiTokenScope =
| 'routes:read' | 'routes:write'
| 'config:read'
| 'certificates:read' | 'certificates:write'
| 'tokens:read' | 'tokens:manage'
| 'source-profiles:read' | 'source-profiles:write'
| 'target-profiles:read' | 'target-profiles:write'
@@ -18,7 +19,11 @@ export type TApiTokenScope =
| 'dns-providers:read' | 'dns-providers:write'
| 'domains:read' | 'domains:write'
| 'dns-records:read' | 'dns-records:write'
| 'acme-config:read' | 'acme-config:write';
| 'acme-config:read' | 'acme-config:write'
| 'email-domains:read' | 'email-domains:write'
| 'workhosters:read' | 'workhosters:write';
export type TWorkHosterType = 'onebox' | 'cloudly' | 'custom';
// ============================================================================
// Source Profile Types (source-side: who can access)
@@ -80,6 +85,12 @@ export interface IRouteMetadata {
networkTargetName?: string;
/** Timestamp of last reference resolution. */
lastResolvedAt?: number;
/** External route ownership, used by WorkHoster reconciliation. */
ownerType?: 'workhoster' | 'operator' | 'system';
workHosterType?: TWorkHosterType;
workHosterId?: string;
workAppId?: string;
externalKey?: string;
}
/**
+108
View File
@@ -0,0 +1,108 @@
import type { IDomain } from './domain.js';
export interface IGatewayCapabilities {
routes: {
read: boolean;
write: boolean;
idempotentSync: boolean;
};
domains: {
read: boolean;
write: boolean;
};
certificates: {
read: boolean;
export: boolean;
forceRenew: boolean;
};
email: {
domains: boolean;
inbound: boolean;
outbound: boolean;
};
remoteIngress: {
enabled: boolean;
};
dns: {
authoritative: boolean;
providerManaged: boolean;
};
http3: {
enabled: boolean;
};
}
export interface IWorkHosterDomain extends IDomain {
capabilities: {
canCreateSubdomains: boolean;
canManageDnsRecords: boolean;
canIssueCertificates: boolean;
canHostEmail: boolean;
};
}
export interface IWorkAppRouteOwnership {
workHosterType: 'onebox' | 'cloudly' | 'custom';
workHosterId: string;
workAppId: string;
hostname: string;
}
export interface IWorkAppRouteSyncResult {
success: boolean;
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
routeId?: string;
message?: string;
}
export interface IWorkAppMailOwnership {
workHosterType: 'onebox' | 'cloudly' | 'custom';
workHosterId: string;
workAppId: string;
}
export interface IWorkAppMailInboundRoute {
enabled: boolean;
targetHost: string;
targetPort: number;
preserveHeaders?: boolean;
addHeaders?: Record<string, string>;
}
export interface IWorkAppMailIdentity {
id: string;
externalKey: string;
ownership: IWorkAppMailOwnership;
address: string;
localPart: string;
domain: string;
enabled: boolean;
displayName?: string;
inbound?: IWorkAppMailInboundRoute;
smtp: {
enabled: boolean;
username: string;
};
createdAt: number;
updatedAt: number;
createdBy: string;
}
export interface IWorkAppMailCredentials {
username: string;
password: string;
host?: string;
ports?: {
smtp?: number;
submission?: number;
smtps?: number;
};
}
export interface IWorkAppMailIdentitySyncResult {
success: boolean;
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
identity?: IWorkAppMailIdentity;
smtpCredentials?: IWorkAppMailCredentials;
message?: string;
}
+12 -6
View File
@@ -28,7 +28,8 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
> {
method: 'getCertificateOverview';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
certificates: ICertificateInfo[];
@@ -50,7 +51,8 @@ export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfa
> {
method: 'reprovisionCertificate';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
};
response: {
@@ -66,7 +68,8 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
> {
method: 'reprovisionCertificateDomain';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
domain: string;
forceRenew?: boolean;
};
@@ -83,7 +86,8 @@ export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.i
> {
method: 'deleteCertificate';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
domain: string;
};
response: {
@@ -99,7 +103,8 @@ export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.i
> {
method: 'exportCertificate';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
domain: string;
};
response: {
@@ -124,7 +129,8 @@ export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.i
> {
method: 'importCertificate';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
cert: {
id: string;
domainName: string;
+1
View File
@@ -19,4 +19,5 @@ export * from './domains.js';
export * from './dns-records.js';
export * from './acme-config.js';
export * from './email-domains.js';
export * from './workhoster.js';
export * from './security-policy.js';
+93
View File
@@ -0,0 +1,93 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type {
IGatewayCapabilities,
IWorkAppMailIdentity,
IWorkAppMailIdentitySyncResult,
IWorkAppMailInboundRoute,
IWorkAppMailOwnership,
IWorkAppRouteOwnership,
IWorkAppRouteSyncResult,
IWorkHosterDomain,
} from '../data/workhoster.js';
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
export interface IReq_GetGatewayCapabilities extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetGatewayCapabilities
> {
method: 'getGatewayCapabilities';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
capabilities: IGatewayCapabilities;
};
}
export interface IReq_GetWorkHosterDomains extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetWorkHosterDomains
> {
method: 'getWorkHosterDomains';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
domains: IWorkHosterDomain[];
};
}
export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SyncWorkAppRoute
> {
method: 'syncWorkAppRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
ownership: IWorkAppRouteOwnership;
route?: IDcRouterRouteConfig;
enabled?: boolean;
delete?: boolean;
};
response: IWorkAppRouteSyncResult;
}
export interface IReq_GetWorkAppMailIdentities extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetWorkAppMailIdentities
> {
method: 'getWorkAppMailIdentities';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
ownership?: Partial<IWorkAppMailOwnership>;
};
response: {
identities: IWorkAppMailIdentity[];
};
}
export interface IReq_SyncWorkAppMailIdentity extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SyncWorkAppMailIdentity
> {
method: 'syncWorkAppMailIdentity';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
ownership: IWorkAppMailOwnership;
localPart: string;
domain: string;
displayName?: string;
inbound?: IWorkAppMailInboundRoute;
enabled?: boolean;
smtpEnabled?: boolean;
resetSmtpPassword?: boolean;
delete?: boolean;
};
response: IWorkAppMailIdentitySyncResult;
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.24.0',
version: '13.25.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+11 -1
View File
@@ -199,12 +199,22 @@ export class OpsViewApiTokens extends DeesElement {
private async showCreateTokenDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
const allScopes: TApiTokenScope[] = [
const allScopes = [
'routes:read',
'routes:write',
'config:read',
'certificates:read',
'certificates:write',
'tokens:read',
'tokens:manage',
'domains:read',
'domains:write',
'dns-records:read',
'dns-records:write',
'email-domains:read',
'email-domains:write',
'workhosters:read',
'workhosters:write',
];
await DeesModal.createAndShow({