Compare commits

..

94 Commits

Author SHA1 Message Date
jkunz d09ac51c5b v13.37.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m10s
2026-05-29 15:21:54 +00:00
jkunz 9d7975721d fix(packaging): exclude assets from compiled and published artifacts 2026-05-29 15:21:22 +00:00
jkunz 667d62b456 v13.37.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 4m28s
2026-05-29 14:52:42 +00:00
jkunz 90b1ca8de3 fix(release): configure pnpm registry for release workflow 2026-05-29 14:45:22 +00:00
jkunz 17d824d718 v13.37.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 20s
2026-05-29 14:05:26 +00:00
jkunz 06a8636aee feat(distribution): add binary installer 2026-05-29 13:58:05 +00:00
jkunz 4bf08c1fc3 fix(distribution): sync Deno binary import map 2026-05-29 10:43:12 +00:00
jkunz 7e721c54d0 feat(distribution): add CLI binary distribution and improve DNS challenge handling 2026-05-29 10:38:54 +00:00
jkunz e6aa5a1dd2 v13.36.3
Docker (tags) / release (push) Failing after 1s
2026-05-29 08:42:32 +00:00
jkunz bbe18e1413 fix(deps): bump smartproxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts 2026-05-29 08:42:14 +00:00
jkunz e2a10bdc3c v13.36.2
Docker (tags) / release (push) Failing after 1s
2026-05-29 04:00:16 +00:00
jkunz 42a5f6df7b fix(dns): preserve parallel ACME TXT challenges and mixed-case DNS queries 2026-05-29 03:59:59 +00:00
jkunz c61d832b43 v13.36.1
Docker (tags) / release (push) Failing after 1s
2026-05-28 14:39:36 +00:00
jkunz 872a822ed7 fix(remoteingress): bump @serve.zone/remoteingress to ^4.18.0 2026-05-28 14:38:57 +00:00
jkunz 34bfd1528b v13.36.0
Docker (tags) / release (push) Failing after 1s
2026-05-28 08:48:03 +00:00
jkunz be38808795 feat(network): add top connected ASN activity to network monitoring 2026-05-28 08:47:12 +00:00
jkunz b9ae4ac344 v13.35.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 05:12:13 +00:00
jkunz 37adcc9ddc feat(vpn): use authenticated VPN route grants 2026-05-24 05:11:48 +00:00
jkunz ac118397f9 v13.34.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 23:45:34 +00:00
jkunz 8188b4712c feat(vpn): allow target profiles to grant non-vpnOnly routes by live client source IP 2026-05-21 23:44:01 +00:00
jkunz 27d077feed v13.33.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 01:56:32 +00:00
jkunz 98913c1977 feat(security): add queued IP intelligence observation and filtered retrieval for network and security views 2026-05-21 01:56:17 +00:00
jkunz ca5c57a329 v13.32.1
Docker (tags) / release (push) Failing after 1s
2026-05-20 16:24:44 +00:00
jkunz 707fbc2413 fix(opsserver,vpn): tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules 2026-05-20 16:24:30 +00:00
jkunz a0c9d40e87 fix(deps): update smartproxy for Alpine compatibility 2026-05-20 15:15:34 +00:00
jkunz 2a73973eda fix(deps): update smartdb for Alpine compatibility 2026-05-20 13:46:01 +00:00
jkunz f0069f87e2 v13.32.0
Docker (tags) / release (push) Failing after 1s
2026-05-19 22:24:40 +00:00
jkunz 77c1738390 feat(ops-auth): add scoped API token auth across ops endpoints 2026-05-19 22:24:37 +00:00
jkunz 53d7c5350e v13.31.0
Docker (tags) / release (push) Failing after 1s
2026-05-19 17:06:52 +00:00
jkunz 7986d01245 feat(opsserver): add admin user create/delete management and default hosted idp.global auth support 2026-05-19 17:06:50 +00:00
jkunz 0b01a4c26b v13.30.0
Docker (tags) / release (push) Failing after 1s
2026-05-18 16:09:40 +00:00
jkunz 407c8eef8a feat(docs): document first-admin bootstrap flow and update authentication examples 2026-05-18 16:09:26 +00:00
jkunz aa0ef2f033 v13.29.1
Docker (tags) / release (push) Failing after 1s
2026-05-14 00:43:14 +00:00
jkunz 7819f09625 fix(smartconfig): enable npm publishing in smartconfig 2026-05-14 00:42:58 +00:00
jkunz 3f8c0c4219 v13.29.0
Docker (tags) / release (push) Failing after 1s
2026-05-14 00:37:15 +00:00
jkunz 70fcd46d52 feat(opsserver-admin): add persisted admin bootstrap flow with optional idp.global authentication 2026-05-14 00:30:09 +00:00
jkunz 47a1f5d7db fix(vpn): harden VPN route access and wireguard client configuration handling 2026-05-13 13:42:12 +00:00
jkunz 67b9fb536c v13.28.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 22:35:07 +00:00
jkunz 8dd0c3def9 feat(gateway-clients): add managed gateway client administration and token-bound route ownership 2026-05-09 22:35:07 +00:00
jkunz d73b250382 v13.27.1
Docker (tags) / release (push) Failing after 1s
2026-05-09 20:02:45 +00:00
jkunz 1c1d55ab8a fix(docker): configure pnpm to use the verdaccio registry during Docker builds 2026-05-09 20:02:45 +00:00
jkunz 2596303c06 v13.27.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 17:30:37 +00:00
jkunz f78bddaede feat(api-token-manager): seed and rotate the environment-managed admin API token during initialization 2026-05-09 17:30:37 +00:00
jkunz a2887d6266 v13.26.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 11:53:45 +00:00
jkunz 97505935bb feat(gateway-clients): add policy-based gateway client tokens and gateway client route and DNS management endpoints 2026-05-09 11:53:45 +00:00
jkunz 7e3b89d9b4 fix: remove default dcrouter admin password 2026-05-08 16:24:45 +00:00
jkunz 7bb6559748 docs: refresh readme and legal info 2026-05-07 20:22:12 +00:00
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
jkunz 89ab918826 v13.24.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 19:51:08 +00:00
jkunz e5c3578163 feat(security): add security policy management and IP intelligence operations to the ops UI 2026-04-26 19:51:08 +00:00
jkunz 1567606c49 v13.23.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 15:15:27 +00:00
jkunz af31982d58 feat(security): add managed security policies with IP intelligence and remote ingress firewall propagation 2026-04-26 15:15:27 +00:00
jkunz a322308623 v13.22.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 12:14:51 +00:00
jkunz ec5374900c feat(remoteingress): add remote ingress performance configuration and expose tunnel transport metrics 2026-04-26 12:14:51 +00:00
jkunz 49ce265d7e fix(deps): bump @push.rocks/smartproxy to ^27.8.2 2026-04-26 11:32:57 +00:00
jkunz 63729697c5 v13.21.1
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 09:29:29 +00:00
jkunz ce93b726ef fix(deps): bump @push.rocks/smartproxy to ^27.8.1 2026-04-26 09:29:29 +00:00
jkunz 1c3aa89f8d v13.21.0
Docker (tags) / security (push) Failing after 10s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-25 20:37:28 +00:00
jkunz b3751abd17 feat(monitoring): improve network activity metrics with live domain request rates and backend identifiers 2026-04-25 20:37:28 +00:00
jkunz 97017ede98 chore(deps): update serve.zone interfaces 2026-04-25 14:01:26 +00:00
jkunz 4b928b038e v13.20.2
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 14:28:19 +00:00
jkunz a466b88408 fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates 2026-04-17 14:28:19 +00:00
jkunz e26ea9e114 v13.20.1
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 13:43:13 +00:00
jkunz c5ca95b6f5 fix(docs): refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance 2026-04-17 13:43:13 +00:00
jkunz 1f25ca4095 v13.20.0
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 06:17:49 +00:00
jkunz 2891e5d3ee feat(routes): add remote ingress controls and preserve-port targeting for route configuration 2026-04-17 06:17:49 +00:00
jkunz 152110c877 v13.19.1
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-16 22:21:07 +00:00
jkunz d780e02928 fix(routes): preserve inline target ports when clearing network target references 2026-04-16 22:21:07 +00:00
jkunz 8bbaf26813 v13.19.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-15 19:59:04 +00:00
jkunz 39f449cbe4 feat(routes,email): persist system DNS routes with runtime hydration and add reusable email ops DNS helpers 2026-04-15 19:59:04 +00:00
jkunz e0386beb15 v13.18.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 13:11:48 +00:00
jkunz 1d7e5495fa feat(email): add persistent smartmta storage and runtime-managed email domain syncing 2026-04-14 13:11:48 +00:00
jkunz 9a378ae87f v13.17.9
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 09:33:41 +00:00
jkunz 58fbc2b1e4 fix(monitoring): align domain activity metrics with id-keyed route data 2026-04-14 09:33:41 +00:00
jkunz 20ea0ce683 v13.17.8
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 01:16:37 +00:00
jkunz bcea93753b fix(opsserver): align certificate status handling with the updated smartproxy response format 2026-04-14 01:16:37 +00:00
jkunz 848515e424 v13.17.7
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:56:31 +00:00
jkunz 38c9978969 fix(repo): no changes to commit 2026-04-14 00:56:31 +00:00
jkunz ee863b8178 v13.17.6
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:53:26 +00:00
jkunz 9bb5a8bcc1 fix(dns,routes): keep DoH socket-handler routes runtime-only and prune stale persisted entries 2026-04-14 00:53:26 +00:00
jkunz 5aa07e81c7 v13.17.5
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 23:02:42 +00:00
jkunz aec8b72ca3 fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior 2026-04-13 23:02:42 +00:00
jkunz 466654ee4c v13.17.3
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:46:12 +00:00
jkunz f1a11e3f6a fix(ops-view-routes): sync route filter toggle selection via component changeSubject 2026-04-13 19:46:12 +00:00
jkunz e193b3a8eb v13.17.2
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:17:46 +00:00
jkunz 1bbf31605c fix(monitoring): exclude unconfigured routes from domain activity aggregation 2026-04-13 19:17:46 +00:00
jkunz f2cfa923a0 v13.17.1
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:15:46 +00:00
jkunz cdc77305e5 fix(monitoring): stop allocating route metrics to domains when no request data exists 2026-04-13 19:15:46 +00:00
jkunz 835537f789 v13.17.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:12:56 +00:00
jkunz 754b223f62 feat(monitoring,network-ui,routes): add request-based domain activity metrics and split routes into user and system views 2026-04-13 19:12:56 +00:00
140 changed files with 16117 additions and 6059 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
+140
View File
@@ -0,0 +1,140 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Configure pnpm registry
run: pnpm config set registry https://verdaccio.lossless.digital/
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Verify package.json version matches tag
run: |
PACKAGE_VERSION=$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")
TAG_VERSION="${{ steps.version.outputs.version_number }}"
echo "package.json version: $PACKAGE_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "ERROR: Version mismatch!"
exit 1
fi
- name: Test package
run: pnpm test
- name: Build binary artifacts
run: pnpm run build:binary
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
cd ../..
- name: Pack npm artifact
run: |
mkdir -p dist/package
pnpm pack --pack-destination dist/package
ls -lh dist/package
- name: Extract changelog for this version
run: |
VERSION="${{ steps.version.outputs.version }}"
if [ -f changelog.md ]; then
awk "/## $VERSION/,/## /" changelog.md | sed '$d' > /tmp/release_notes.md || true
fi
if [ ! -s /tmp/release_notes.md ]; then
cat > /tmp/release_notes.md << EOF
## DcRouter $VERSION
NodeNext package build plus self-extracting Linux binaries.
### Artifacts
- npm package tarball
- dcrouter-linux-x64
- dcrouter-linux-arm64
- SHA256SUMS.txt
EOF
fi
- name: Delete existing release if it exists
run: |
VERSION="${{ steps.version.outputs.version }}"
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/tags/$VERSION" \
| jq -r '.id // empty')
if [ -n "$EXISTING_RELEASE_ID" ]; then
curl -X DELETE -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$EXISTING_RELEASE_ID"
sleep 2
fi
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases" \
-d "{
\"tag_name\": \"$VERSION\",
\"name\": \"DcRouter $VERSION\",
\"body\": $(jq -Rs . /tmp/release_notes.md),
\"draft\": false,
\"prerelease\": false
}" | jq -r '.id')
for artifact in dist/package/* dist/binaries/*; do
[ -f "$artifact" ] || continue
filename=$(basename "$artifact")
curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$artifact" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$RELEASE_ID/assets?name=$filename"
done
- name: Release Summary
run: |
echo "Release ${{ steps.version.outputs.version }} complete"
ls -lh dist/package
ls -lh dist/binaries
+56 -20
View File
@@ -23,14 +23,39 @@
"outputMode": "bundle",
"bundler": "esbuild",
"production": true,
"includeFiles": ["./html/**/*.html"]
"includeFiles": [
"./html/**/*.html"
]
}
]
},
"@git.zone/tsdeno": {
"compileTargets": [
{
"name": "dcrouter-linux-x64",
"entryPoint": "binary/dcrouter.ts",
"outDir": "dist/binaries",
"target": "x86_64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true,
"selfExtracting": true
},
{
"name": "dcrouter-linux-arm64",
"entryPoint": "binary/dcrouter.ts",
"outDir": "dist/binaries",
"target": "aarch64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true,
"selfExtracting": true
}
]
},
"@git.zone/cli": {
"schemaVersion": 2,
"projectType": "service",
"module": {
"githost": "gitlab.com",
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "dcrouter",
"description": "A traffic router intended to be gating your datacenter.",
@@ -60,26 +85,37 @@
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
"targets": {
"git": {
"enabled": true,
"remote": "origin"
},
"npm": {
"enabled": true,
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"docker": {
"enabled": true,
"engine": "tsdocker"
}
}
}
},
"@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"],
"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"]
}
}
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"@ship.zone/szci": {}
}
+10 -6
View File
@@ -1,12 +1,18 @@
# 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 registry https://verdaccio.lossless.digital/
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules && pnpm install
RUN pnpm install --frozen-lockfile
COPY . ./
RUN pnpm run build
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 +24,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"
+4
View File
@@ -0,0 +1,4 @@
process.env.CLI_CALL = 'true';
const cliTool = await import('../dist_ts/index.js');
await cliTool.runCli();
+358 -1
View File
@@ -1,5 +1,362 @@
# Changelog
## Pending
## 2026-05-29 - 13.37.2
### Fixes
- exclude assets from compiled and published artifacts (packaging)
- Removed assets from the Deno compile include list.
- Removed assets from the npm package files list.
## 2026-05-29 - 13.37.1
### Fixes
- configure pnpm registry for release workflow (release)
- Sets the pnpm registry before dependency installation so release builds resolve packages from the configured registry.
## 2026-05-29 - 13.37.0
### Features
- add CLI binary distribution (distribution)
- Add dcrouter bin entry, Deno compile targets, binary entrypoint, and tag-driven release workflow for Linux artifacts.
- Add --version and --help handling to the CLI for safe package and binary smoke tests.
- Keep the Deno binary import map aligned with the current SmartDNS and SmartProxy runtime dependencies.
- add one-line installer and Docker distribution docs (distribution)
- Add an install.sh flow that installs Linux x64 and arm64 release binaries by default with a NodeNext source-build fallback.
- Document installer modes, binary artifact names, and the published multi-arch Docker image.
## 2026-05-29 - 13.36.3
### Fixes
- update SmartProxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts
- Bump @push.rocks/smartproxy to ^27.11.1.
- Prevent public gateway WebSocket routes from inheriting the HTTP socket timeout.
- bump smartproxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts (deps)
- Bump @push.rocks/smartproxy to ^27.11.1.
- Prevent public gateway WebSocket routes from inheriting the HTTP socket timeout.
## 2026-05-29 - 13.36.2
### Fixes
- preserve parallel ACME DNS-01 TXT challenges and consume case-insensitive DNS matching (dns,certificates)
- Keep exact and wildcard SAN challenge TXT records at the same owner name instead of deleting sibling challenge values.
- Match local dcrouter-hosted DNS records case-insensitively so DNS 0x20 mixed-case queries keep resolving.
- Update @push.rocks/smartdns to 7.9.3 for case-insensitive handler matching in the embedded DNS server.
- preserve parallel ACME TXT challenges and mixed-case DNS queries (dns)
- Remove only matching ACME DNS-01 TXT challenge values during setup and cleanup so parallel challenges can coexist.
- Resolve locally hosted DNS records case-insensitively while preserving the query name casing in responses.
- Bump @push.rocks/smartdns to ^7.9.3.
## 2026-05-28 - 13.36.1
### Fixes
- consume RemoteIngress 4.18.0 tunnel performance improvements (remoteingress)
- Update @serve.zone/remoteingress to 4.18.0 so DcRouter uses zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix.
- bump @serve.zone/remoteingress to ^4.18.0 (remoteingress)
- Updates @serve.zone/remoteingress from ^4.17.1 to ^4.18.0.
- Consumes zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix from RemoteIngress.
## 2026-05-28 - 13.36.0
### Features
- add top connected ASN activity to Network Activity (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose ASN activity through network stats and combined metrics APIs.
- Add a Network Activity table with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
- add top connected ASN activity to network monitoring (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose top ASN activity through network stats and combined metrics API responses.
- Add a Network Activity table for top ASNs with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
## 2026-05-24 - 13.35.0
### Features
- switch VPN route authorization to authenticated SmartVPN metadata (vpn)
- configure SmartVPN to forward real client source IPs plus VPN metadata through trusted PROXY v2 headers
- map target profiles to SmartProxy VPN client grants instead of mutating route source IP allow lists
- keep live VPN client source IP tracking as status/UI data while SmartProxy enforces source policy per connection
## 2026-05-21 - 13.34.0
### Features
- allow VPN target profiles to grant routes by live client source IP (vpn)
- Add an opt-in target profile flag that evaluates non-vpnOnly route source security against the VPN client's real connecting IP.
- Track live VPN client source IPs from smartvpn remote addresses and WireGuard peer endpoints, refreshing routes when they change.
- Expose the setting and current source IPs in the Ops UI with regression coverage for source-IP matching behavior.
- allow target profiles to grant non-vpnOnly routes by live client source IP (vpn)
- add an opt-in target profile flag to match route source security against a VPN client's real connecting IP
- track live client source IPs from VPN remote addresses and WireGuard peer endpoints and re-apply routes when they change
- expose source IP access settings and current client source IPs through the ops API and UI
- add regression tests for source-IP route matching, block-list handling, vpnOnly exclusions, and WireGuard endpoint refresh
## 2026-05-21 - 13.33.0
### Features
- add queued IP intelligence observation and filtered retrieval for network and security views (security)
- Queue observed public IPs from network metrics with throttled background enrichment instead of awaiting lookups during stats collection.
- Allow listing IP intelligence records by specific IP addresses and limit through the security handler and request interface.
- Update web app state to refresh IP intelligence asynchronously in the background and preserve current UI state during refreshes.
- Improve security policy manager observation handling so forced refresh waits for in-flight lookups before fetching updated intelligence.
## 2026-05-20 - 13.32.1
### Fixes
- tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules (opsserver,vpn)
- Block ephemeral admin bootstrap login and user listing until the configured database is ready, and report bootstrap availability accurately in admin status responses.
- Preserve persisted admin accounts across OpsServer restarts with added regression coverage.
- Merge matching VPN client IPs into restricted non-vpnOnly route allow lists without duplicating entries.
- Handle string and wildcard route domains consistently when resolving target profile access and VPN client matches.
## 2026-05-19 - 13.32.0
### Features
- add scoped API token auth across ops endpoints (ops-auth)
- introduces a shared requireOpsAuth helper that validates JWT identities and API tokens with scope and admin-policy checks
- applies explicit per-endpoint authorization across config, logs, stats, security, VPN, RADIUS, remote ingress, users, API tokens, and related ops handlers
- extends request interfaces and UI scope definitions to support apiToken-based access and adds tests for auth behavior and migration bridging
## 2026-05-19 - 13.31.0
### Features
- add admin user create/delete management and default hosted idp.global auth support (opsserver)
- adds admin-only createUser and deleteUser typed requests with safeguards against deleting the current user or last active admin
- updates the ops users UI to create and delete users, show richer account details, and support optional idp.global login during account creation
- treats idp.global as available by default via the hosted https://idp.global endpoint while keeping URL settings as optional overrides
- adds VPN-only route controls and indicators in the ops routes UI
## 2026-05-18 - 13.30.0
### Features
- document first-admin bootstrap flow and update authentication examples (docs)
- Add README guidance for explicit initial admin creation on DB-backed instances across the main package, API client, interfaces, and web dashboard docs.
- Update authentication examples to use persisted admin email/password credentials instead of the old default admin login.
- Refresh dependency versions in package.json to align documentation with current package releases.
## 2026-05-14 - 13.29.1
### Fixes
- enable npm publishing in smartconfig (smartconfig)
- Sets the npm integration flag to true in .smartconfig.json
- Keeps the configured Verdaccio and npmjs registries unchanged
## 2026-05-14 - 13.29.0
### Fixes
- harden VPN route access and wireguard client configuration handling (vpn)
- Fail closed for vpnOnly routes when no VPN client IPs are available by replacing allow lists and enforcing a block-all fallback
- Refresh route application and VPN client security after target profile creation so profile changes take effect immediately
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
### Features
- add persisted admin bootstrap flow with optional idp.global authentication (opsserver-admin)
- introduces bootstrap status and initial admin creation endpoints for OpsServer
- switches admin authentication from ephemeral-only users to database-backed accounts when a persistent admin exists
- adds optional idp.global login support for admin accounts and exposes auth source metadata in user listings
- updates the web dashboard to prompt creation of the first persisted admin account
- adds integration coverage for bootstrap, persisted login, identity invalidation, and user listing behavior
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
add managed gateway client administration and token-bound route ownership
- introduce persistent gateway client management with create, update, delete, list, and scoped token creation flows
- add gateway client context and ownership resolution so token-bound clients can sync routes without spoofing another client
- surface gateway client administration in the ops dashboard with a new Access > Gateway Clients view
- mark certificate provisioning backoff failures as failed and expose root-cause errors with DNS management guidance in the certificates view
## 2026-05-09 - 13.27.1 - fix(docker)
configure pnpm to use the verdaccio registry during Docker builds
- Adds a pnpm registry configuration step before dependency installation in the Dockerfile.
- Ensures container builds resolve packages from the configured Verdaccio registry.
## 2026-05-09 - 13.27.0 - feat(api-token-manager)
seed and rotate the environment-managed admin API token during initialization
- Add initialization support for DCROUTER_ADMIN_API_TOKEN with validation, persistence, and admin policy assignment
- Ensure the environment-managed token is updated when the configured raw token changes
- Refactor token hashing into a shared helper and add coverage for seeding, validation, redaction, and rotation behavior
## 2026-05-09 - 13.26.0 - feat(gateway-clients)
add policy-based gateway client tokens and gateway client route and DNS management endpoints
- Introduces API token policies with admin and gatewayClient roles, capability checks, hostname restrictions, and allowed route targets.
- Adds gateway client request and data interfaces for domains, DNS records, route sync, and ownership metadata while keeping workhoster aliases for compatibility.
- Extends route metadata normalization to prefer gatewayClient ownership and updates generated route names and test coverage accordingly.
## 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
- adds typed request endpoints to fetch compiled security policy, list audit events, and force-refresh IP intelligence
- introduces dedicated security policy state and actions for loading, creating, updating, deleting, and refreshing security data
- enhances the network activity view with IP intelligence columns, detail dialogs, and block-rule actions
- expands the security blocked view into a full management interface for rules, compiled policy, IP intelligence, and audit history
## 2026-04-26 - 13.23.0 - feat(security)
add managed security policies with IP intelligence and remote ingress firewall propagation
- introduces a SecurityPolicyManager that observes public IPs, stores IP intelligence, compiles block policies, and audits policy changes
- adds database documents and shared interfaces for security block rules, IP intelligence records, and security policy audit events
- exposes ops/admin request handlers to list IP intelligence and create, update, or delete security block rules
- applies merged security policies to SmartProxy and propagates firewall snapshots to remote ingress edges and tunnel synchronization
## 2026-04-26 - 13.22.0 - feat(remoteingress)
add remote ingress performance configuration and expose tunnel transport metrics
- upgrade @serve.zone/remoteingress to support performance tuning and richer tunnel status data
- pass remote ingress performance settings through router startup and config APIs
- serialize allowed-edge sync operations and await route update hooks to avoid tunnel sync races
- expose UDP listen ports and transport, flow control, queue, and traffic metrics in remote ingress APIs and ops UI
## 2026-04-26 - 13.21.1 - fix(deps)
bump @push.rocks/smartproxy to ^27.8.1
- Updates @push.rocks/smartproxy from ^27.8.0 to ^27.8.1 in package.json.
## 2026-04-25 - 13.21.0 - feat(monitoring)
improve network activity metrics with live domain request rates and backend identifiers
- use SmartProxy per-domain live request rates to rank and attribute domain activity metrics, while retaining lifetime request totals as fallback data
- separate aggregate backend rows from protocol cache rows with stable ids so cached protocol entries no longer duplicate active backend connection counts
- expose frontend and backend protocol distributions plus aggregated connectionCount fields through ops and web network views
## 2026-04-17 - 13.20.2 - fix(vpn)
handle VPN forwarding mode downgrades and support runtime VPN config updates
- restart the VPN server back to socket mode when host-IP clients are removed while preserving explicit hybrid mode
- allow DcRouter to update VPN configuration at runtime and refresh route allow-list resolution without recreating the router
- improve VPN operations UI target profile rendering and loading behavior for create and edit flows
## 2026-04-17 - 13.20.1 - fix(docs)
refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance
- Reworks the main README with updated positioning, quick-start examples, route ownership guidance, configuration notes, automation examples, and OCI bootstrap details
- Expands package-specific readmes for the runtime, API client, interfaces, migrations, and web dashboard to better describe exports, behavior, and usage
- Standardizes documentation references such as subpath import guidance and LICENSE link casing across readmes
## 2026-04-17 - 13.20.0 - feat(routes)
add remote ingress controls and preserve-port targeting for route configuration
- Allow route updates to remove optional top-level properties by treating null values like remoteIngress as explicit clears.
- Add route form support for preserving the matched incoming port when forwarding to backend targets.
- Add remote ingress enablement and edge filter controls to route create/edit views.
- Cover remoteIngress removal behavior with a runtime route manager test.
## 2026-04-16 - 13.19.1 - fix(routes)
preserve inline target ports when clearing network target references
- Normalize route metadata so empty reference fields are removed instead of persisted.
- Allow the routes UI to clear source profile and network target references explicitly during edits.
- Disable inline target host and port inputs when a network target is selected and validate target ports when using manual targets.
- Add runtime route tests covering removal of a network target reference while keeping the edited inline target port.
## 2026-04-15 - 13.19.0 - feat(routes,email)
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers
- Persist seeded DNS-over-HTTPS routes with stable system keys and hydrate socket handlers at runtime instead of treating them as runtime-only routes
- Restrict system-managed routes to toggle-only operations across the route manager, Ops API, and web UI while returning explicit mutation errors
- Add a shared email DNS record builder and cover email queue operations and handler behavior with new tests
## 2026-04-14 - 13.18.0 - feat(email)
add persistent smartmta storage and runtime-managed email domain syncing
- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence
- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart
- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation
## 2026-04-14 - 13.17.9 - fix(monitoring)
align domain activity metrics with id-keyed route data
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
- Add a regression test covering domain activity aggregation for routes identified only by id.
- Update the network activity UI to show formatted total connection counts in the active connections card.
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
## 2026-04-14 - 13.17.8 - fix(opsserver)
align certificate status handling with the updated smartproxy response format
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
- bump @push.rocks/smartproxy to ^27.7.3
- enable verbose output for the test script
## 2026-04-14 - 13.17.7 - fix(repo)
no changes to commit
## 2026-04-14 - 13.17.6 - fix(dns,routes)
keep DoH socket-handler routes runtime-only and prune stale persisted entries
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
normalize target profile route references and stabilize VPN host-IP client routing behavior
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
sync route filter toggle selection via component changeSubject
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
## 2026-04-13 - 13.17.2 - fix(monitoring)
exclude unconfigured routes from domain activity aggregation
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
## 2026-04-13 - 13.17.1 - fix(monitoring)
stop allocating route metrics to domains when no request data exists
- Removes the equal-split fallback for shared routes in MetricsManager.
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
add request-based domain activity metrics and split routes into user and system views
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
## 2026-04-13 - 13.16.2 - fix(deps)
bump @push.rocks/smartproxy to ^27.6.0
@@ -2435,4 +2792,4 @@ Applied a core fix.
- Fixed core functionality for version 1.0.1
–––––––––––––––––––––––
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.37.2",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
"dist_serve"
]
},
"imports": {
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/server",
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.1",
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.2",
"@push.rocks/smartmetrics": "npm:@push.rocks/smartmetrics@^3.0.3",
"@push.rocks/smartmigration": "npm:@push.rocks/smartmigration@1.4.1",
"@push.rocks/smartmta": "npm:@push.rocks/smartmta@^5.3.3",
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.11.1",
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.18.0",
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
"lru-cache": "npm:lru-cache@^11.4.0",
"qrcode": "npm:qrcode@^1.5.4",
"uuid": "npm:uuid@^14.0.0"
}
}
Executable
+359
View File
@@ -0,0 +1,359 @@
#!/bin/bash
# DcRouter Installer Script
# Installs the self-extracting Linux binary by default, or builds the NodeNext
# source package when --source is specified.
#
# Usage:
# Binary install:
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
#
# Source install:
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
#
# Options:
# -h, --help Show this help message
# --version VERSION Install a specific tag/version (e.g. vX.Y.Z)
# --install-dir DIR Installation directory (default: /opt/dcrouter)
# --binary Install release binary (default)
# --source Clone the tag and build the NodeNext package locally
set -euo pipefail
SHOW_HELP=0
SPECIFIED_VERSION=""
INSTALL_DIR="/opt/dcrouter"
INSTALL_MODE="binary"
GITEA_BASE_URL="https://code.foss.global"
GITEA_REPO="serve.zone/dcrouter"
SERVICE_NAME="dcrouter"
BIN_DIR="/usr/local/bin"
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
SHOW_HELP=1
shift
;;
--version)
if [[ $# -lt 2 ]]; then
echo "Error: --version requires a value"
exit 1
fi
SPECIFIED_VERSION="$2"
shift 2
;;
--install-dir)
if [[ $# -lt 2 ]]; then
echo "Error: --install-dir requires a value"
exit 1
fi
INSTALL_DIR="$2"
shift 2
;;
--binary)
INSTALL_MODE="binary"
shift
;;
--source)
INSTALL_MODE="source"
shift
;;
*)
echo "Unknown option: $1"
echo "Use -h or --help for usage information"
exit 1
;;
esac
done
if [[ $SHOW_HELP -eq 1 ]]; then
echo "DcRouter Installer Script"
echo "Installs DcRouter as a self-extracting binary or NodeNext source build."
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --version VERSION Install a specific tag/version (e.g. vX.Y.Z)"
echo " --install-dir DIR Installation directory (default: /opt/dcrouter)"
echo " --binary Install release binary (default)"
echo " --source Clone the tag and build the NodeNext package locally"
echo ""
echo "Examples:"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --version vX.Y.Z"
exit 0
fi
if [[ "$EUID" -ne 0 ]]; then
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
exit 1
fi
case "$INSTALL_DIR" in
""|"/")
echo "Error: unsafe install directory: $INSTALL_DIR"
exit 1
;;
esac
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: required command not found: $1"
exit 1
fi
}
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return
fi
if command -v corepack >/dev/null 2>&1; then
corepack enable
fi
if ! command -v pnpm >/dev/null 2>&1; then
echo "Error: pnpm is required for --source installs. Install Node.js with corepack/pnpm first."
exit 1
fi
}
make_executable_if_present() {
if [[ -f "$1" ]]; then
chmod 0755 "$1"
fi
}
get_latest_version() {
echo "Fetching latest release version from Gitea..." >&2
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
local response
if ! response=$(curl -fsSL "$api_url" 2>/dev/null); then
echo "Error: Failed to fetch latest release information from Gitea API" >&2
echo "URL: $api_url" >&2
exit 1
fi
local version
version=$(printf '%s' "$response" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [[ -z "$version" ]]; then
echo "Error: Could not determine latest version from API response" >&2
exit 1
fi
echo "$version"
}
detect_binary_name() {
local os
local arch
os=$(uname -s)
arch=$(uname -m)
if [[ "$os" != "Linux" ]]; then
echo "Error: binary installer currently supports Linux only. Use --source for this platform." >&2
exit 1
fi
case "$arch" in
x86_64|amd64)
echo "dcrouter-linux-x64"
;;
aarch64|arm64)
echo "dcrouter-linux-arm64"
;;
*)
echo "Error: unsupported architecture for binary install: $arch. Use --source." >&2
exit 1
;;
esac
}
echo "================================================"
echo " DcRouter Installation Script"
echo "================================================"
echo ""
require_command curl
require_command sed
if [[ -n "$SPECIFIED_VERSION" ]]; then
VERSION="$SPECIFIED_VERSION"
echo "Installing specified version: $VERSION"
else
VERSION=$(get_latest_version)
echo "Installing latest version: $VERSION"
fi
echo "Install mode: $INSTALL_MODE"
echo ""
SOURCE_REF="$VERSION"
REPO_URL="${GITEA_BASE_URL}/${GITEA_REPO}.git"
TEMP_DIR=$(mktemp -d)
SOURCE_DIR="$TEMP_DIR/source"
BACKUP_DIR=""
SERVICE_WAS_RUNNING=0
SERVICE_STOPPED=0
SYSTEMD_AVAILABLE=0
cleanup_temp() {
rm -rf "$TEMP_DIR"
}
trap cleanup_temp EXIT
if command -v systemctl >/dev/null 2>&1; then
SYSTEMD_AVAILABLE=1
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
SERVICE_WAS_RUNNING=1
fi
fi
restore_previous_installation() {
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
echo "Restoring previous installation from $BACKUP_DIR..."
rm -rf "$INSTALL_DIR" || true
mv "$BACKUP_DIR" "$INSTALL_DIR" || true
if [[ -f "$INSTALL_DIR/dcrouter" ]]; then
mkdir -p "$BIN_DIR" || true
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter" || true
elif [[ -f "$INSTALL_DIR/cli.js" ]]; then
mkdir -p "$BIN_DIR" || true
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter" || true
fi
fi
}
restart_previous_service_on_error() {
if [[ $SERVICE_STOPPED -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
echo "Installation failed after stopping DcRouter; restarting previous service..."
systemctl start "$SERVICE_NAME" || true
fi
}
handle_install_error() {
trap - ERR
restore_previous_installation
restart_previous_service_on_error
}
trap handle_install_error ERR
stop_service_if_running() {
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
echo "Stopping DcRouter service..."
systemctl stop "$SERVICE_NAME"
SERVICE_STOPPED=1
fi
}
move_previous_installation() {
mkdir -p "$(dirname "$INSTALL_DIR")"
if [[ -d "$INSTALL_DIR" ]]; then
BACKUP_DIR="${INSTALL_DIR}.previous.$$"
echo "Moving previous installation to $BACKUP_DIR"
mv "$INSTALL_DIR" "$BACKUP_DIR"
fi
}
install_source_build() {
require_command git
require_command node
ensure_pnpm
echo "Cloning DcRouter source from $REPO_URL ($SOURCE_REF)..."
git clone --depth 1 --branch "$SOURCE_REF" "$REPO_URL" "$SOURCE_DIR"
echo "Installing dependencies..."
pnpm --dir "$SOURCE_DIR" install --frozen-lockfile
echo "Building DcRouter..."
pnpm --dir "$SOURCE_DIR" run build
echo "Validating built CLI..."
node "$SOURCE_DIR/cli.js" --version >/dev/null
stop_service_if_running
move_previous_installation
echo "Installing source build to $INSTALL_DIR"
mv "$SOURCE_DIR" "$INSTALL_DIR"
make_executable_if_present "$INSTALL_DIR/cli.js"
make_executable_if_present "$INSTALL_DIR/cli.ts.js"
make_executable_if_present "$INSTALL_DIR/cli.child.js"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter"
}
install_release_binary() {
local binary_name
local download_url
local temp_file
binary_name=$(detect_binary_name)
download_url="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${binary_name}"
temp_file="$TEMP_DIR/$binary_name"
echo "Downloading DcRouter binary: $download_url"
curl -fSL "$download_url" -o "$temp_file"
chmod 0755 "$temp_file"
echo "Validating downloaded binary..."
"$temp_file" --version >/dev/null
stop_service_if_running
move_previous_installation
echo "Installing binary to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
install -m 0755 "$temp_file" "$INSTALL_DIR/dcrouter"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter"
}
if [[ "$INSTALL_MODE" == "source" ]]; then
install_source_build
else
install_release_binary
fi
echo "Symlink created: $BIN_DIR/dcrouter"
if ! "$BIN_DIR/dcrouter" --version >/dev/null; then
echo "Error: Installed DcRouter CLI failed validation"
restore_previous_installation
restart_previous_service_on_error
exit 1
fi
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
rm -rf "$BACKUP_DIR"
fi
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
echo "Restarting DcRouter service..."
systemctl restart "$SERVICE_NAME"
SERVICE_STOPPED=0
echo "Service restarted successfully."
echo ""
fi
trap - ERR
echo "================================================"
echo " DcRouter Installation Complete!"
echo "================================================"
echo ""
echo "Installation details:"
echo " Install directory: $INSTALL_DIR"
echo " Symlink location: $BIN_DIR/dcrouter"
echo " Version: $VERSION"
echo " Mode: $INSTALL_MODE"
echo ""
echo "Get started:"
echo ""
echo " dcrouter --version"
echo " dcrouter --help"
echo ""
+46 -42
View File
@@ -1,9 +1,12 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.16.2",
"version": "13.37.2",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
"dcrouter": "./cli.js"
},
"exports": {
".": "./dist_ts/index.js",
"./interfaces": "./dist_ts_interfaces/index.js",
@@ -12,64 +15,68 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --logfile --timeout 60)",
"test": "(tstest test/ --verbose --logfile --timeout 60)",
"start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"build": "(tsbuild tsfolders --allowimplicitany && pnpm run bundle)",
"build:binary": "(pnpm run build && tsdeno compile)",
"build:docker": "tsdocker build --verbose",
"release:docker": "tsdocker push --verbose",
"bundle": "(tsbundle)",
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.6.0"
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.4.0",
"@git.zone/tsdeno": "^1.4.0",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest": "^3.3.1",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@api.global/typedsocket": "^4.1.3",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.78.2",
"@design.estate/dees-catalog": "^3.83.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@idp.global/sdk": "^1.3.1",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdb": "^2.6.2",
"@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartdb": "^2.10.1",
"@push.rocks/smartdns": "^7.9.3",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartjwt": "^2.2.2",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartmigration": "1.4.1",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.6.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.11.1",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.2",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.3",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.18.0",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.3.3",
"lru-cache": "^11.4.0",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"keywords": [
"mail service",
@@ -97,25 +104,22 @@
"VLAN assignment",
"MAC authentication"
],
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
},
"packageManager": "pnpm@10.11.0",
"files": [
"ts/**/*",
"binary/**/*",
"ts_web/**/*",
"ts_apiclient/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"dist_ts_apiclient/**/*",
"assets/**/*",
"cli.js",
"cli.ts.js",
"cli.child.js",
"cli.child.ts",
"deno.json",
"tsconfig.json",
".smartconfig.json",
"readme.md"
]
+2277 -2165
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
mongodb-memory-server: true
puppeteer: true
+214 -1565
View File
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { TypedRequest } from '@api.global/typedrequest';
import { OpsServer } from '../ts/opsserver/index.js';
import { DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
const testPort = 3110;
const baseUrl = `http://localhost:${testPort}/typedrequest`;
const bootstrapPassword = 'temporary-bootstrap-password';
const persistedPassword = 'persisted-admin-password';
let previousAdminPassword: string | undefined;
let opsServer: OpsServer;
let testDb: DcRouterDb;
let storagePath: string;
let dbName: string;
let bootstrapIdentity: interfaces.data.IIdentity;
let persistedIdentity: interfaces.data.IIdentity;
let createdUserId: string;
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
baseUrl,
'getAdminBootstrapStatus',
);
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
baseUrl,
'adminLoginWithUsernameAndPassword',
);
const createFakeDcRouter = (portArg: number, dcRouterDbArg?: DcRouterDb) => ({
options: {
opsServerPort: portArg,
dbConfig: { enabled: true },
adminAuth: {
idpClient: {
loginWithEmailAndPassword: async () => ({
jwt: 'idp-jwt',
refreshToken: 'idp-refresh-token',
user: {
id: 'idp-user-1',
data: {
name: 'Wrong IdP User',
username: 'wrong@example.com',
email: 'wrong@example.com',
status: 'active',
connectedOrgs: [],
},
},
}),
stop: async () => {},
},
},
},
typedrouter: new plugins.typedrequest.TypedRouter(),
dcRouterDb: dcRouterDbArg,
});
const restartOpsServer = async () => {
await opsServer.stop();
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start();
};
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
testDb = DcRouterDb.getInstance({
storagePath,
dbName,
});
await testDb.start();
await testDb.getDb().mongoDb.createCollection('__test_init');
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start();
});
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
const status = await createStatusRequest().fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(true);
expect(status.hasPersistentAdmin).toEqual(false);
expect(status.needsBootstrap).toEqual(true);
expect(status.ephemeralAdminAvailable).toEqual(true);
expect(status.idpGlobalConfigured).toEqual(true);
});
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
const response = await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
if (!response.identity) {
throw new Error('Expected bootstrap login identity');
}
bootstrapIdentity = response.identity;
expect(bootstrapIdentity.role).toEqual('admin');
});
tap.test('creates the initial persisted admin explicitly', async () => {
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
baseUrl,
'createInitialAdminUser',
);
const response = await request.fire({
identity: bootstrapIdentity,
email: 'Admin@Example.com',
name: 'Persisted Admin',
password: persistedPassword,
enableIdpGlobalAuth: true,
});
expect(response.success).toEqual(true);
expect(response.user?.role).toEqual('admin');
expect(response.user?.authSources).toContain('local');
expect(response.user?.authSources).toContain('idp.global');
if (!response.identity) {
throw new Error('Expected persisted admin identity');
}
persistedIdentity = response.identity;
});
tap.test('disables bootstrap mode after persisted admin exists', async () => {
const status = await createStatusRequest().fire({});
expect(status.hasPersistentAdmin).toEqual(true);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
});
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
baseUrl,
'verifyIdentity',
);
const response = await request.fire({ identity: bootstrapIdentity });
expect(response.valid).toEqual(false);
});
tap.test('authenticates the persisted admin locally by normalized email', async () => {
const response = await createLoginRequest().fire({
username: 'admin@example.com',
password: persistedPassword,
authSource: 'local',
});
if (!response.identity) {
throw new Error('Expected persisted admin login identity');
}
expect(response.identity.userId).toEqual(persistedIdentity.userId);
});
tap.test('persists users across OpsServer restart', async () => {
const oldPersistedIdentity = persistedIdentity;
await restartOpsServer();
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
baseUrl,
'verifyIdentity',
);
const verifyResponse = await verifyRequest.fire({ identity: oldPersistedIdentity });
expect(verifyResponse.valid).toEqual(false);
const loginResponse = await createLoginRequest().fire({
username: 'admin@example.com',
password: persistedPassword,
authSource: 'local',
});
if (!loginResponse.identity) {
throw new Error('Expected persisted admin login identity after restart');
}
expect(loginResponse.identity.userId).toEqual(oldPersistedIdentity.userId);
persistedIdentity = loginResponse.identity;
});
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin@example.com',
password: 'idp-password',
authSource: 'idp.global',
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('creates a persisted non-admin user explicitly', async () => {
const request = new TypedRequest<interfaces.requests.IReq_CreateUser>(baseUrl, 'createUser');
const response = await request.fire({
identity: persistedIdentity,
email: 'operator@example.com',
name: 'Operator User',
role: 'user',
password: 'operator-password',
});
expect(response.success).toEqual(true);
expect(response.user?.role).toEqual('user');
expect(response.user?.email).toEqual('operator@example.com');
if (!response.user?.id) {
throw new Error('Expected created user id');
}
createdUserId = response.user.id;
});
tap.test('rejects deleting the current persisted admin user', async () => {
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
const response = await request.fire({
identity: persistedIdentity,
id: persistedIdentity.userId,
});
expect(response.success).toEqual(false);
});
tap.test('deletes a persisted non-current user', async () => {
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
const response = await request.fire({
identity: persistedIdentity,
id: createdUserId,
});
expect(response.success).toEqual(true);
});
tap.test('lists persisted users without password material', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
const response = await request.fire({ identity: persistedIdentity });
expect(response.users.length).toEqual(1);
expect(response.users[0].email).toEqual('Admin@Example.com');
expect((response.users[0] as any).password).toBeUndefined();
});
tap.test('rejects temporary bootstrap admin when persisted-user database is unavailable', async () => {
await testDb.stop();
const status = await createStatusRequest().fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(false);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
await opsServer.stop();
await testDb.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
if (previousAdminPassword === undefined) {
delete process.env.DCROUTER_ADMIN_PASSWORD;
} else {
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
}
});
tap.test('does not offer bootstrap while configured database is unavailable', async () => {
const unavailablePort = 3111;
const unavailableBaseUrl = `http://localhost:${unavailablePort}/typedrequest`;
const previousUnavailableAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = 'unavailable-bootstrap-password';
DcRouterDb.resetInstance();
const unavailableOpsServer = new OpsServer(createFakeDcRouter(unavailablePort) as any);
try {
await unavailableOpsServer.start();
const status = await new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
unavailableBaseUrl,
'getAdminBootstrapStatus',
).fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(false);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
let rejected = false;
try {
await new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
unavailableBaseUrl,
'adminLoginWithUsernameAndPassword',
).fire({
username: 'admin',
password: 'unavailable-bootstrap-password',
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
} finally {
await unavailableOpsServer.stop();
DcRouterDb.resetInstance();
if (previousUnavailableAdminPassword === undefined) {
delete process.env.DCROUTER_ADMIN_PASSWORD;
} else {
process.env.DCROUTER_ADMIN_PASSWORD = previousUnavailableAdminPassword;
}
}
});
export default tap.start();
+75
View File
@@ -0,0 +1,75 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ApiTokenManager } from '../ts/config/classes.api-token-manager.js';
import { DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
tap.test('ApiTokenManager seeds and rotates an env admin API token', async () => {
const previousToken = process.env.DCROUTER_ADMIN_API_TOKEN;
const previousName = process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
const testDb = await createTestDb();
try {
const rawToken1 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
const rawToken2 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken1;
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = 'Onebox Managed Admin';
const manager = new ApiTokenManager();
await manager.initialize();
const token1 = await manager.validateToken(rawToken1);
expect(token1?.id).toEqual('env-admin-token');
expect(token1?.name).toEqual('Onebox Managed Admin');
expect(token1?.policy?.role).toEqual('admin');
expect(manager.hasScope(token1!, 'tokens:manage')).toEqual(true);
const listedToken = manager.listTokens().find((token) => token.id === 'env-admin-token') as any;
expect(listedToken.tokenHash).toBeUndefined();
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken2;
const rotatedManager = new ApiTokenManager();
await rotatedManager.initialize();
expect(await rotatedManager.validateToken(rawToken1)).toBeNull();
const token2 = await rotatedManager.validateToken(rawToken2);
expect(token2?.id).toEqual('env-admin-token');
expect(token2?.policy?.role).toEqual('admin');
} finally {
if (previousToken === undefined) {
delete process.env.DCROUTER_ADMIN_API_TOKEN;
} else {
process.env.DCROUTER_ADMIN_API_TOKEN = previousToken;
}
if (previousName === undefined) {
delete process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
} else {
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = previousName;
}
await testDb.cleanup();
}
});
export default tap.start();
+201
View File
@@ -0,0 +1,201 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
import { AcmeCertDoc, DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-cert-api-token-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const makeApiTokenManager = (scopes: TScope[]) => {
const token = {
id: 'token-1',
name: 'certificate-test-token',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope),
};
};
const setupHandler = (scopes: TScope[], options?: {
routes?: any[];
certProvisionScheduler?: any;
certProvisionFunction?: (...args: any[]) => any;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = {
typedrouter,
adminHandler: {
validateIdentity: async () => null,
adminIdentityGuard: {
exec: async () => false,
},
},
dcRouterRef: {
apiTokenManager: makeApiTokenManager(scopes),
certificateStatusMap: new Map(),
smartProxy: {
settings: options?.certProvisionFunction ? {
certProvisionFunction: options.certProvisionFunction,
} : {},
routeManager: { getRoutes: () => options?.routes ?? [] },
getCertificateStatus: async () => null,
},
certProvisionScheduler: options?.certProvisionScheduler ?? null,
},
};
new CertificateHandler(opsServerRef);
return { typedrouter, opsServerRef };
};
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const testDbPromise = createTestDb();
tap.test('CertificateHandler allows API-token export with certificates:read', async () => {
await testDbPromise;
const certDoc = new AcmeCertDoc();
certDoc.id = 'cert-1';
certDoc.domainName = 'example.com';
certDoc.created = 1;
certDoc.validUntil = 2;
certDoc.privateKey = '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----';
certDoc.publicKey = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----';
certDoc.csr = '';
await certDoc.save();
const { typedrouter } = setupHandler(['certificates:read']);
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
apiToken: 'valid-token',
domain: 'example.com',
});
expect(result.error).toBeUndefined();
expect(result.response.success).toEqual(true);
expect(result.response.cert.domainName).toEqual('example.com');
expect(result.response.cert.privateKey).toContain('BEGIN PRIVATE KEY');
expect(result.response.cert.publicKey).toContain('BEGIN CERTIFICATE');
});
tap.test('CertificateHandler rejects API-token export without certificates:read', async () => {
const { typedrouter } = setupHandler(['certificates:write']);
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
apiToken: 'valid-token',
domain: 'example.com',
});
expect(result.error?.text).toEqual('insufficient scope');
});
tap.test('CertificateHandler allows API-token import with certificates:write', async () => {
await testDbPromise;
const { typedrouter, opsServerRef } = setupHandler(['certificates:write']);
const result = await fireTypedRequest(typedrouter, 'importCertificate', {
apiToken: 'valid-token',
cert: {
id: 'cert-2',
domainName: 'imported.example.com',
created: 3,
validUntil: 4,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
},
});
expect(result.error).toBeUndefined();
expect(result.response.success).toEqual(true);
expect((await AcmeCertDoc.findByDomain('imported.example.com'))?.id).toEqual('cert-2');
expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid');
});
tap.test('CertificateHandler reports active certificate backoff as failed with root cause', async () => {
await testDbPromise;
const lastError = 'DNS-01 failed for stack.gallery: DnsManager: no managed domain found for _acme-challenge.stack.gallery.';
const retryAfter = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const { typedrouter } = setupHandler(['certificates:read'], {
certProvisionFunction: async () => 'http01',
certProvisionScheduler: {
getBackoffInfo: async (domain: string) => domain === 'stack.gallery'
? { failures: 11, retryAfter, lastError }
: null,
},
routes: [
{
name: 'stack-gallery',
match: { domains: ['stack.gallery'] },
action: {
tls: {
mode: 'terminate',
certificate: 'auto',
},
},
},
],
});
const result = await fireTypedRequest(typedrouter, 'getCertificateOverview', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.summary.failed).toEqual(1);
expect(result.response.certificates[0].status).toEqual('failed');
expect(result.response.certificates[0].error).toEqual(lastError);
expect(result.response.certificates[0].backoffInfo.failures).toEqual(11);
});
tap.test('cleanup test db', async () => {
const testDb = await testDbPromise;
await testDb.cleanup();
});
export default tap.start();
+79
View File
@@ -0,0 +1,79 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ConfigHandler } from '../ts/opsserver/handlers/config.handler.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeOpsServer = (scopes: interfaces.data.TApiTokenScope[]) => {
const router = new plugins.typedrequest.TypedRouter();
const token = {
id: 'token-1',
name: 'config-token',
tokenHash: 'hash',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
const opsServerRef = {
viewRouter: router,
adminHandler: {
validateIdentity: async () => null,
},
dcRouterRef: {
options: {
dbConfig: { enabled: false },
},
resolvedPaths: {
dcrouterHomeDir: '/tmp/dcrouter-home',
dataDir: '/tmp/dcrouter-data',
defaultTsmDbPath: '/tmp/dcrouter-data/db',
},
detectedPublicIp: null,
apiTokenManager: {
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: interfaces.data.TApiTokenScope) => storedTokenArg.scopes.includes(scopeArg),
},
},
} as any;
new ConfigHandler(opsServerRef);
return router;
};
tap.test('ConfigHandler accepts API token with config:read', async () => {
const router = makeOpsServer(['config:read']);
const result = await fireTypedRequest(router, 'getConfiguration', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.config.system.baseDir).toEqual('/tmp/dcrouter-home');
});
tap.test('ConfigHandler rejects API token without config:read', async () => {
const router = makeOpsServer(['logs:read']);
const result = await fireTypedRequest(router, 'getConfiguration', {
apiToken: 'valid-token',
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+3
View File
@@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
// Verify unified email server was initialized
expect(router.emailServer).toBeTruthy();
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
// Stop the router
await router.stop();
+469
View File
@@ -0,0 +1,469 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DnsRecordDoc, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DnsManager } from '../ts/dns/manager.dns.js';
import { logger } from '../ts/logger.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const record of await DnsRecordDoc.findAll()) {
await record.delete();
}
for (const route of await RouteDoc.findAll()) {
await route.delete();
}
for (const domain of await DomainDoc.findAll()) {
await domain.delete();
}
};
tap.test('DnsManager keeps parallel ACME TXT challenges for the same host', async () => {
await testDbPromise;
await clearTestState();
const now = Date.now();
const domain = new DomainDoc();
domain.id = 'central-eu';
domain.name = 'central.eu';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'test';
await domain.save();
const dnsManager = new DnsManager({});
const provider = dnsManager.buildAcmeConvenientDnsProvider().convenience as any;
const hostName = '_acme-challenge.blog.central.eu';
await provider.acmeSetDnsChallenge({ hostName, challenge: 'first-token' });
await provider.acmeSetDnsChallenge({ hostName, challenge: 'second-token' });
const recordsAfterSet = await DnsRecordDoc.findByDomainId(domain.id);
expect(recordsAfterSet.map((record) => record.value).sort()).toEqual([
'first-token',
'second-token',
]);
await provider.acmeRemoveDnsChallenge({ hostName, challenge: 'first-token' });
const recordsAfterRemove = await DnsRecordDoc.findByDomainId(domain.id);
expect(recordsAfterRemove.map((record) => record.value)).toEqual(['second-token']);
});
tap.test('DnsManager local records answer mixed-case DNS queries', async () => {
await testDbPromise;
await clearTestState();
const now = Date.now();
const domain = new DomainDoc();
domain.id = 'central-eu';
domain.name = 'central.eu';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'test';
await domain.save();
const registeredHandlers: Array<(question: { name: string; type: string }) => any> = [];
const dnsManager = new DnsManager({});
dnsManager.dnsServer = {
registerHandler: (_name: string, _types: string[], handler: (question: { name: string; type: string }) => any) => {
registeredHandlers.push(handler);
},
} as any;
await dnsManager.createRecord({
domainId: domain.id,
name: '_acme-challenge.central.eu',
type: 'TXT',
value: 'challenge-token',
ttl: 120,
createdBy: 'test',
});
const answer = registeredHandlers[0]?.({
name: '_aCMe-challeNge.Central.Eu',
type: 'txt',
});
expect(answer).toEqual({
name: '_aCMe-challeNge.Central.Eu',
type: 'TXT',
class: 'IN',
ttl: 120,
data: 'challenge-token',
});
});
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const persistedRoutes = await RouteDoc.findAll();
expect(persistedRoutes.length).toEqual(2);
expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
const mergedRoutes = routeManager.getMergedRoutes().routes;
expect(mergedRoutes.length).toEqual(2);
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
expect(appliedRoutes.length).toEqual(1);
for (const routeSet of appliedRoutes) {
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
expect(dnsQueryRoute).toBeDefined();
expect(resolveRoute).toBeDefined();
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
}
});
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const staleDnsQueryRoute = new RouteDoc();
staleDnsQueryRoute.id = 'stale-doh-query';
staleDnsQueryRoute.route = {
name: 'dns-over-https-dns-query',
match: {
ports: [443],
domains: ['ns1.example.com'],
path: '/dns-query',
},
action: {
type: 'socket-handler' as any,
} as any,
};
staleDnsQueryRoute.enabled = true;
staleDnsQueryRoute.createdAt = Date.now();
staleDnsQueryRoute.updatedAt = Date.now();
staleDnsQueryRoute.createdBy = 'test';
staleDnsQueryRoute.origin = 'dns';
await staleDnsQueryRoute.save();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const remainingRoutes = await RouteDoc.findAll();
expect(remainingRoutes.length).toEqual(2);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
expect(queryRoute?.id).toEqual('stale-doh-query');
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
});
tap.test('RouteConfigManager only allows toggling system routes', async () => {
await testDbPromise;
await clearTestState();
const smartProxy = {
updateRoutes: async (_routes: any[]) => {
return;
},
};
const routeManager = new RouteConfigManager(() => smartProxy as any);
await routeManager.initialize([
{
name: 'system-config-route',
match: {
ports: [443],
domains: ['app.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
tls: { mode: 'terminate' as const },
},
} as any,
], [], []);
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
expect(systemRoute).toBeDefined();
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
route: { name: 'renamed-system-route' } as any,
});
expect(updateResult.success).toEqual(false);
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
expect(deleteResult.success).toEqual(false);
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
expect(toggleResult.success).toEqual(true);
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
});
tap.test('RouteConfigManager clears a network target ref and keeps the edited inline target port', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const resolver = new ReferenceResolver();
(resolver as any).targets.set('target-1', {
id: 'target-1',
name: 'SSH TARGET',
host: '10.0.0.5',
port: 443,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'test',
});
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
resolver,
);
await routeManager.initialize([], [], []);
const routeId = await routeManager.createRoute(
{
name: 'ssh-route',
match: { ports: [22] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 22 }],
},
} as any,
'test-user',
true,
{ networkTargetRef: 'target-1' },
);
expect((await RouteDoc.findById(routeId))?.route.action.targets?.[0].port).toEqual(443);
expect((await RouteDoc.findById(routeId))?.metadata?.networkTargetRef).toEqual('target-1');
const updateResult = await routeManager.updateRoute(routeId, {
route: {
action: {
targets: [{ host: '127.0.0.1', port: 29424 }],
},
} as any,
metadata: {
networkTargetRef: '',
networkTargetName: '',
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.action.targets?.[0].host).toEqual('127.0.0.1');
expect(storedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(storedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(storedRoute?.metadata?.networkTargetName).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(mergedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(mergedRoute?.metadata?.networkTargetName).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].action.targets[0].port).toEqual(29424);
});
tap.test('RouteConfigManager clears remote ingress config when route patch sets it to null', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
);
await routeManager.initialize([], [], []);
const routeId = await routeManager.createRoute(
{
name: 'remote-ingress-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
remoteIngress: {
enabled: true,
edgeFilter: ['edge-a', 'blue'],
},
} as any,
'test-user',
);
const updateResult = await routeManager.updateRoute(routeId, {
route: {
remoteIngress: null,
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.remoteIngress).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.remoteIngress).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
});
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
await testDbPromise;
await clearTestState();
const originalLog = logger.log.bind(logger);
const warningMessages: string[] = [];
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
if (level === 'warn') {
warningMessages.push(message);
}
return originalLog(level, message, context || {});
};
try {
const existingDomain = new DomainDoc();
existingDomain.id = 'existing-domain';
existingDomain.name = 'example.com';
existingDomain.source = 'dcrouter';
existingDomain.authoritative = true;
existingDomain.createdAt = Date.now();
existingDomain.updatedAt = Date.now();
existingDomain.createdBy = 'test';
await existingDomain.save();
const dnsManager = new DnsManager({
dnsNsDomains: ['ns1.example.com'],
dnsScopes: ['example.com'],
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
smartProxyConfig: { routes: [] },
});
await dnsManager.start();
expect(
warningMessages.some((message) =>
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
),
).toEqual(true);
expect(
warningMessages.some((message) =>
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
),
).toEqual(false);
} finally {
(logger as any).log = originalLog;
}
});
tap.test('cleanup test db', async () => {
await clearTestState();
const testDb = await testDbPromise;
await testDb.cleanup();
});
export default tap.start();
+65
View File
@@ -0,0 +1,65 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { buildEmailDnsRecords } from '../ts/email/index.js';
tap.test('buildEmailDnsRecords uses the configured mail hostname for MX and includes DKIM when provided', async () => {
const records = buildEmailDnsRecords({
domain: 'example.com',
hostname: 'mail.example.com',
selector: 'selector1',
dkimValue: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
statuses: {
mx: 'valid',
spf: 'missing',
dkim: 'valid',
dmarc: 'unchecked',
},
});
expect(records).toEqual([
{
type: 'MX',
name: 'example.com',
value: '10 mail.example.com',
status: 'valid',
},
{
type: 'TXT',
name: 'example.com',
value: 'v=spf1 a mx ~all',
status: 'missing',
},
{
type: 'TXT',
name: 'selector1._domainkey.example.com',
value: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
status: 'valid',
},
{
type: 'TXT',
name: '_dmarc.example.com',
value: 'v=DMARC1; p=none; rua=mailto:dmarc@example.com',
status: 'unchecked',
},
]);
});
tap.test('buildEmailDnsRecords omits DKIM when no value is provided', async () => {
const records = buildEmailDnsRecords({
domain: 'example.net',
hostname: 'smtp.example.net',
mxPriority: 20,
});
expect(records.map((record) => record.name)).toEqual([
'example.net',
'example.net',
'_dmarc.example.net',
]);
expect(records[0].value).toEqual('20 smtp.example.net');
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();
+193
View File
@@ -0,0 +1,193 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { EmailDomainManager } from '../ts/email/index.js';
import { DcRouterDb, DomainDoc } from '../ts/db/index.js';
import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const emailDomain of await EmailDomainDoc.findAll()) {
await emailDomain.delete();
}
for (const domain of await DomainDoc.findAll()) {
await domain.delete();
}
};
const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => {
const doc = new DomainDoc();
doc.id = id;
doc.name = name;
doc.source = source;
doc.authoritative = source === 'dcrouter';
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
doc.createdBy = 'test';
await doc.save();
return doc;
};
const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({
ports: [2525],
hostname: 'mail.example.com',
domains: [
{
domain: 'static.example.com',
dnsMode: 'external-dns',
},
],
routes: [],
});
tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider');
const updateCalls: Array<{ domains?: any[] }> = [];
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
emailServer: {
updateOptions: (options: { domains?: any[] }) => {
updateCalls.push(options);
},
},
};
const manager = new EmailDomainManager(dcRouterStub);
await manager.start();
const created = await manager.createEmailDomain({
linkedDomainId: linkedDomain.id,
subdomain: 'mail',
dkimSelector: 'selector1',
rotateKeys: true,
rotationIntervalDays: 30,
});
const domainsAfterCreate = dcRouterStub.options.emailConfig.domains;
expect(domainsAfterCreate.length).toEqual(2);
expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true);
const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com');
expect(managedDomain).toBeTruthy();
expect(managedDomain?.dnsMode).toEqual('external-dns');
expect(managedDomain?.dkim?.selector).toEqual('selector1');
expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true);
await manager.updateEmailDomain(created.id, {
rotateKeys: false,
rateLimits: {
outbound: {
messagesPerMinute: 10,
},
},
});
const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains;
const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com');
expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false);
expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10);
await manager.deleteEmailDomain(created.id);
expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']);
});
tap.test('EmailDomainManager rejects domains already present in static config', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider');
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
};
const manager = new EmailDomainManager(dcRouterStub);
let error: Error | undefined;
try {
await manager.createEmailDomain({ linkedDomainId: linkedDomain.id });
} catch (err: unknown) {
error = err as Error;
}
expect(error?.message).toEqual('Email domain already configured for static.example.com');
});
tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter');
const stored = new EmailDomainDoc();
stored.id = 'managed-email-domain';
stored.domain = 'mail.managed.example.com';
stored.linkedDomainId = linkedDomain.id;
stored.subdomain = 'mail';
stored.dkim = {
selector: 'default',
keySize: 2048,
rotateKeys: false,
rotationIntervalDays: 90,
};
stored.dnsStatus = {
mx: 'unchecked',
spf: 'unchecked',
dkim: 'unchecked',
dmarc: 'unchecked',
};
stored.createdAt = new Date().toISOString();
stored.updatedAt = new Date().toISOString();
await stored.save();
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
};
const manager = new EmailDomainManager(dcRouterStub);
await manager.start();
const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com');
expect(managedDomain?.dnsMode).toEqual('internal-dns');
});
tap.test('cleanup', async () => {
const testDb = await testDbPromise;
await clearTestState();
await testDb.cleanup();
await tap.stopForcefully();
});
export default tap.start();
+175
View File
@@ -0,0 +1,175 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { TypedRequest } from '@api.global/typedrequest';
import { DcRouter } from '../ts/index.js';
import * as interfaces from '../ts_interfaces/index.js';
const TEST_PORT = 3201;
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
const TEST_ADMIN_PASSWORD = 'test-admin-password';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
let removedQueueItemId: string | undefined;
let lastEnqueueArgs: any[] | undefined;
const queueItems = [
{
id: 'failed-email-1',
status: 'failed',
attempts: 3,
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
lastError: '550 mailbox unavailable',
processingMode: 'mta',
route: undefined,
createdAt: new Date('2026-04-14T09:00:00.000Z'),
processingResult: {
from: 'sender@example.com',
to: ['recipient@example.net'],
cc: ['copy@example.net'],
subject: 'Older message',
text: 'hello',
headers: { 'x-test': '1' },
getMessageId: () => 'message-older',
getAttachmentsSize: () => 64,
},
},
{
id: 'delivered-email-1',
status: 'delivered',
attempts: 1,
processingMode: 'mta',
route: undefined,
createdAt: new Date('2026-04-14T11:00:00.000Z'),
processingResult: {
email: {
from: 'fresh@example.com',
to: ['new@example.net'],
cc: [],
subject: 'Newest message',
},
html: '<p>newest</p>',
text: 'newest',
headers: { 'x-fresh': 'true' },
getMessageId: () => 'message-newer',
getAttachmentsSize: () => 0,
},
},
];
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
testDcRouter = new DcRouter({
opsServerPort: TEST_PORT,
dbConfig: { enabled: false },
});
await testDcRouter.start();
testDcRouter.emailServer = {
getQueueItems: () => [...queueItems],
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
getQueueStats: () => ({
queueSize: 2,
status: {
pending: 0,
processing: 1,
failed: 1,
deferred: 1,
delivered: 1,
},
}),
deliveryQueue: {
enqueue: async (...args: any[]) => {
lastEnqueueArgs = args;
return 'resent-queue-id';
},
removeItem: async (id: string) => {
removedQueueItemId = id;
return true;
},
},
} as any;
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
});
tap.test('should login as admin for email API tests', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
BASE_URL,
'adminLoginWithUsernameAndPassword',
);
const response = await loginRequest.fire({
username: 'admin',
password: TEST_ADMIN_PASSWORD,
});
const responseIdentity = response.identity;
expect(responseIdentity).toBeDefined();
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
expect(adminIdentity.jwt).toBeTruthy();
});
tap.test('should return queued emails through the email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetAllEmails>(BASE_URL, 'getAllEmails');
const response = await request.fire({
identity: adminIdentity,
});
expect(response.emails.map((email) => email.id)).toEqual(['delivered-email-1', 'failed-email-1']);
expect(response.emails[0].status).toEqual('delivered');
expect(response.emails[1].status).toEqual('bounced');
});
tap.test('should return email detail through the email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetEmailDetail>(BASE_URL, 'getEmailDetail');
const response = await request.fire({
identity: adminIdentity,
emailId: 'failed-email-1',
});
expect(response.email?.toList).toEqual(['recipient@example.net']);
expect(response.email?.cc).toEqual(['copy@example.net']);
expect(response.email?.rejectionReason).toEqual('550 mailbox unavailable');
expect(response.email?.headers).toEqual({ 'x-test': '1' });
});
tap.test('should expose queue status through the stats API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetQueueStatus>(BASE_URL, 'getQueueStatus');
const response = await request.fire({
identity: adminIdentity,
});
expect(response.queues.length).toEqual(1);
expect(response.queues[0].size).toEqual(0);
expect(response.queues[0].processing).toEqual(1);
expect(response.queues[0].failed).toEqual(1);
expect(response.queues[0].retrying).toEqual(1);
expect(response.totalItems).toEqual(3);
});
tap.test('should resend failed email through the admin email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ResendEmail>(BASE_URL, 'resendEmail');
const response = await request.fire({
identity: adminIdentity,
emailId: 'failed-email-1',
});
expect(response.success).toEqual(true);
expect(response.newQueueId).toEqual('resent-queue-id');
expect(removedQueueItemId).toEqual('failed-email-1');
expect(lastEnqueueArgs?.[0]).toEqual(queueItems[0].processingResult);
});
tap.test('should stop DCRouter after email API tests', async () => {
await testDcRouter.stop();
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();
+107
View File
@@ -0,0 +1,107 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EmailOpsHandler } from '../ts/opsserver/handlers/email-ops.handler.js';
import { StatsHandler } from '../ts/opsserver/handlers/stats.handler.js';
const createRouterStub = () => ({
addTypedHandler: (_handler: unknown) => {},
});
const queueItems = [
{
id: 'older-failed',
status: 'failed',
attempts: 3,
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
lastError: '550 mailbox unavailable',
createdAt: new Date('2026-04-14T09:00:00.000Z'),
processingResult: {
from: 'sender@example.com',
to: ['recipient@example.net'],
cc: ['copy@example.net'],
subject: 'Older message',
text: 'hello',
headers: { 'x-test': '1' },
getMessageId: () => 'message-older',
getAttachmentsSize: () => 64,
},
},
{
id: 'newer-delivered',
status: 'delivered',
attempts: 1,
createdAt: new Date('2026-04-14T11:00:00.000Z'),
processingResult: {
email: {
from: 'fresh@example.com',
to: ['new@example.net'],
cc: [],
subject: 'Newest message',
},
html: '<p>newest</p>',
text: 'newest',
headers: { 'x-fresh': 'true' },
getMessageId: () => 'message-newer',
getAttachmentsSize: () => 0,
},
},
];
tap.test('EmailOpsHandler maps queue items using public email server APIs', async () => {
const opsHandler = new EmailOpsHandler({
viewRouter: createRouterStub(),
adminRouter: createRouterStub(),
dcRouterRef: {
emailServer: {
getQueueItems: () => queueItems,
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
},
},
} as any);
const emails = (opsHandler as any).getAllQueueEmails();
expect(emails.map((email: any) => email.id)).toEqual(['newer-delivered', 'older-failed']);
expect(emails[0].status).toEqual('delivered');
expect(emails[1].status).toEqual('bounced');
expect(emails[0].messageId).toEqual('message-newer');
const detail = (opsHandler as any).getEmailDetail('older-failed');
expect(detail?.toList).toEqual(['recipient@example.net']);
expect(detail?.cc).toEqual(['copy@example.net']);
expect(detail?.rejectionReason).toEqual('550 mailbox unavailable');
expect(detail?.headers).toEqual({ 'x-test': '1' });
});
tap.test('StatsHandler reports queue status using public email server APIs', async () => {
const statsHandler = new StatsHandler({
viewRouter: createRouterStub(),
dcRouterRef: {
emailServer: {
getQueueStats: () => ({
queueSize: 2,
status: {
pending: 0,
processing: 1,
failed: 1,
deferred: 1,
delivered: 1,
},
}),
getQueueItems: () => queueItems,
},
},
} as any);
const queueStatus = await (statsHandler as any).getQueueStatus();
expect(queueStatus.pending).toEqual(0);
expect(queueStatus.active).toEqual(1);
expect(queueStatus.failed).toEqual(1);
expect(queueStatus.retrying).toEqual(1);
expect(queueStatus.items.map((item: any) => item.id)).toEqual(['newer-delivered', 'older-failed']);
expect(queueStatus.items[1].nextRetry).toEqual(new Date('2026-04-14T10:00:00.000Z').getTime());
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();
+9
View File
@@ -103,6 +103,9 @@ tap.test('ErrorHandler should properly handle and format errors', async () => {
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
} catch (error) {
expect(error).toBeInstanceOf(PlatformError);
if (!(error instanceof PlatformError)) {
throw error;
}
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
expect(error.context.operation).toEqual('testExecution');
}
@@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => {
}
);
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
expect(error.message).toEqual('Critical error');
expect(attempts).toEqual(1); // Should only attempt once
}
@@ -262,6 +268,9 @@ tap.test('Error handling can be combined with retry for robust operations', asyn
// Should not reach here
expect(false).toEqual(true);
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
expect(error.message).toContain('Flaky failure');
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
}
+28 -14
View File
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let identity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3102,
@@ -25,18 +27,22 @@ tap.test('should login with admin credentials and receive JWT', async () => {
const response = await loginRequest.fire({
username: 'admin',
password: 'admin'
password: testAdminPassword
});
expect(response).toHaveProperty('identity');
expect(response.identity).toHaveProperty('jwt');
expect(response.identity).toHaveProperty('userId');
expect(response.identity).toHaveProperty('name');
expect(response.identity).toHaveProperty('expiresAt');
expect(response.identity).toHaveProperty('role');
expect(response.identity.role).toEqual('admin');
identity = response.identity;
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
expect(responseIdentity).toHaveProperty('jwt');
expect(responseIdentity).toHaveProperty('userId');
expect(responseIdentity).toHaveProperty('name');
expect(responseIdentity).toHaveProperty('expiresAt');
expect(responseIdentity).toHaveProperty('role');
expect(responseIdentity.role).toEqual('admin');
identity = responseIdentity;
console.log('JWT:', identity.jwt);
});
@@ -53,7 +59,11 @@ tap.test('should verify valid JWT identity', async () => {
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
expect(response).toHaveProperty('identity');
expect(response.identity.userId).toEqual(identity.userId);
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected verify response to include identity');
}
expect(responseIdentity.userId).toEqual(identity.userId);
});
tap.test('should reject invalid JWT', async () => {
@@ -86,8 +96,12 @@ tap.test('should verify JWT matches identity data', async () => {
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
expect(response.identity.userId).toEqual(identity.userId);
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected verify response to include identity');
}
expect(responseIdentity.expiresAt).toEqual(identity.expiresAt);
expect(responseIdentity.userId).toEqual(identity.userId);
});
tap.test('should handle logout', async () => {
@@ -129,4 +143,4 @@ tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();
export default tap.start();
+329
View File
@@ -0,0 +1,329 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
const emptyProtocolDistribution = {
h1Active: 0,
h1Total: 0,
h2Active: 0,
h2Total: 0,
h3Active: 0,
h3Total: 0,
wsActive: 0,
wsTotal: 0,
otherActive: 0,
otherTotal: 0,
};
function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>;
domainRequestsByIP: Map<string, Map<string, number>>;
domainRequestRates?: Map<string, { perSecond: number; lastMinute: number }>;
backendMetrics?: Map<string, any>;
protocolCache?: any[];
requestsTotal?: number;
connectionsByIP?: Map<string, number>;
throughputByIP?: Map<string, { in: number; out: number }>;
}) {
const connectionsByIP = args.connectionsByIP || new Map<string, number>();
const throughputByIP = args.throughputByIP || new Map<string, { in: number; out: number }>();
return {
connections: {
active: () => 0,
total: () => 0,
byRoute: () => args.connectionsByRoute,
byIP: () => connectionsByIP,
topIPs: (limit = 10) => Array.from(connectionsByIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, count]) => ({ ip, count })),
domainRequestsByIP: () => args.domainRequestsByIP,
topDomainRequests: () => [],
frontendProtocols: () => emptyProtocolDistribution,
backendProtocols: () => emptyProtocolDistribution,
},
throughput: {
instant: () => ({ in: 0, out: 0 }),
recent: () => ({ in: 0, out: 0 }),
average: () => ({ in: 0, out: 0 }),
custom: () => ({ in: 0, out: 0 }),
history: () => [],
byRoute: () => args.throughputByRoute,
byIP: () => throughputByIP,
},
requests: {
perSecond: () => 0,
perMinute: () => 0,
total: () => args.requestsTotal || 0,
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
},
totals: {
bytesIn: () => 0,
bytesOut: () => 0,
connections: () => 0,
},
backends: {
byBackend: () => args.backendMetrics || new Map<string, any>(),
protocols: () => new Map<string, string>(),
topByErrors: () => [],
detectedProtocols: () => args.protocolCache || [],
},
};
}
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map([
['route-id-only', 4],
]),
throughputByRoute: new Map([
['route-id-only', { in: 1200, out: 2400 }],
]),
domainRequestsByIP: new Map([
['192.0.2.10', new Map([
['alpha.example.com', 3],
['beta.example.com', 1],
])],
]),
requestsTotal: 4,
});
const smartProxy = {
getMetrics: () => proxyMetrics,
routeManager: {
getRoutes: () => [
{
id: 'route-id-only',
match: {
ports: [443],
domains: ['alpha.example.com', 'beta.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
},
],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
expect(alpha).toBeDefined();
expect(beta).toBeDefined();
expect(alpha!.requestCount).toEqual(3);
expect(alpha!.routeCount).toEqual(1);
expect(alpha!.activeConnections).toEqual(3);
expect(alpha!.bytesInPerSecond).toEqual(900);
expect(alpha!.bytesOutPerSecond).toEqual(1800);
expect(beta!.requestCount).toEqual(1);
expect(beta!.routeCount).toEqual(1);
expect(beta!.activeConnections).toEqual(1);
expect(beta!.bytesInPerSecond).toEqual(300);
expect(beta!.bytesOutPerSecond).toEqual(600);
});
tap.test('MetricsManager prefers live domain request rates for current activity', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map([
['route-id-only', 10],
]),
throughputByRoute: new Map([
['route-id-only', { in: 1000, out: 1000 }],
]),
domainRequestsByIP: new Map([
['192.0.2.10', new Map([
['alpha.example.com', 1000],
['beta.example.com', 1],
])],
]),
domainRequestRates: new Map([
['alpha.example.com', { perSecond: 0, lastMinute: 0 }],
['beta.example.com', { perSecond: 5, lastMinute: 60 }],
]),
});
const smartProxy = {
getMetrics: () => proxyMetrics,
routeManager: {
getRoutes: () => [
{
id: 'route-id-only',
match: {
ports: [443],
domains: ['alpha.example.com', 'beta.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
},
],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
expect(alpha!.activeConnections).toEqual(0);
expect(alpha!.requestsPerSecond).toEqual(0);
expect(beta!.activeConnections).toEqual(10);
expect(beta!.requestsPerSecond).toEqual(5);
expect(beta!.bytesInPerSecond).toEqual(1000);
});
tap.test('MetricsManager does not duplicate backend active counts onto protocol cache rows', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
backendMetrics: new Map([
['192.0.2.1:443', {
protocol: 'h2',
activeConnections: 257,
totalConnections: 1000,
connectErrors: 1,
handshakeErrors: 2,
requestErrors: 3,
avgConnectTimeMs: 4,
poolHitRate: 0.9,
h2Failures: 5,
}],
]),
protocolCache: [
{
host: '192.0.2.1',
port: 443,
domain: 'alpha.example.com',
protocol: 'h2',
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
ageSecs: 1,
},
{
host: '192.0.2.1',
port: 443,
domain: 'beta.example.com',
protocol: 'h2',
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
ageSecs: 1,
},
],
});
const smartProxy = {
getMetrics: () => proxyMetrics,
routeManager: {
getRoutes: () => [],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const aggregate = stats.backends.find((item) => item.id === 'backend:192.0.2.1:443');
const cacheRows = stats.backends.filter((item) => item.id?.startsWith('cache:'));
expect(aggregate!.activeConnections).toEqual(257);
expect(cacheRows.length).toEqual(2);
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
});
tap.test('MetricsManager queues IP intelligence without awaiting enrichment', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['1.1.1.1', 2],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['1.1.1.1', { in: 1500, out: 1000 }],
]),
});
const queuedIps: string[][] = [];
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
listIpIntelligence: async () => [],
},
} as any);
await manager.getNetworkStats();
expect(queuedIps).toHaveLength(1);
expect(queuedIps[0]).toContain('8.8.8.8');
expect(queuedIps[0]).toContain('1.1.1.1');
});
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['8.8.4.4', 3],
['1.1.1.1', 5],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['8.8.4.4', { in: 700, out: 350 }],
['1.1.1.1', { in: 2000, out: 1000 }],
]),
});
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: () => undefined,
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
},
} as any);
const stats = await manager.getNetworkStats();
expect(stats.topASNs).toHaveLength(2);
expect(stats.topASNs[0].asn).toEqual(15169);
expect(stats.topASNs[0].organization).toEqual('Google LLC');
expect(stats.topASNs[0].activeConnections).toEqual(7);
expect(stats.topASNs[0].ipCount).toEqual(2);
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
expect(stats.topASNs[1].asn).toEqual(13335);
expect(stats.topASNs[1].activeConnections).toEqual(5);
});
export default tap.start();
+69
View File
@@ -0,0 +1,69 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createMigrationRunner } from '../ts_migrations/index.js';
function setPath(target: Record<string, any>, path: string, value: unknown): void {
const parts = path.split('.');
let cursor = target;
for (const part of parts.slice(0, -1)) {
cursor[part] = cursor[part] || {};
cursor = cursor[part];
}
cursor[parts[parts.length - 1]] = value;
}
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
for (const [key, value] of Object.entries(set)) {
setPath(document, key, value);
}
}
function createFakeDb(currentVersion: string) {
const ledgerDocument = {
nameId: 'smartmigration:smartmigration',
data: {
currentVersion,
steps: {},
lock: { holder: null, acquiredAt: null, expiresAt: null },
checkpoints: {},
},
};
const emptyCollection = {
find: () => ({
async *[Symbol.asyncIterator]() {},
}),
updateMany: async () => ({ modifiedCount: 0 }),
};
const ledgerCollection = {
createIndex: async () => undefined,
findOne: async () => structuredClone(ledgerDocument),
findOneAndUpdate: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return structuredClone(ledgerDocument);
},
updateOne: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
return {
mongoDb: {
collection: (name: string) =>
name === 'SmartdataEasyStore' ? ledgerCollection : emptyCollection,
},
};
}
tap.test('migration runner bridges old package-version targets without real schema steps', async () => {
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.31.0');
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.31.0');
expect(result.stepsApplied).toHaveLength(3);
});
export default tap.start();
+126
View File
@@ -0,0 +1,126 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { requireOpsAuth } from '../ts/opsserver/helpers/auth.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const makeIdentity = (role: string = 'user'): interfaces.data.IIdentity => ({
jwt: `jwt-${role}`,
userId: `${role}-user`,
name: role,
expiresAt: Date.now() + 3600000,
role,
});
const makeOpsServer = (options: {
identityRole?: string | null;
tokenScopes?: TScope[];
tokenPolicy?: interfaces.data.IApiTokenPolicy;
}) => {
const token = {
id: 'token-1',
name: 'test-token',
tokenHash: 'hash',
scopes: options.tokenScopes || [],
policy: options.tokenPolicy,
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
createdBy: 'token-user',
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
adminHandler: {
validateIdentity: async (identityArg?: interfaces.data.IIdentity) => {
if (!identityArg || options.identityRole === null) return null;
return { ...identityArg, role: options.identityRole || identityArg.role || 'user' };
},
},
dcRouterRef: {
apiTokenManager: {
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: TScope) => {
if (storedTokenArg.policy?.role === 'admin') return true;
return storedTokenArg.scopes.includes('*') || storedTokenArg.scopes.includes(scopeArg) || Boolean(storedTokenArg.policy?.scopes?.includes(scopeArg));
},
},
},
} as any;
};
const getErrorText = (errorArg: unknown) => {
return (errorArg as any).errorText || (errorArg as any).text || (errorArg as Error).message;
};
tap.test('requireOpsAuth accepts valid JWT identity for read endpoints', async () => {
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: 'user' }),
{ identity: makeIdentity('user') },
{ scope: 'config:read' },
);
expect(auth.type).toEqual('identity');
expect(auth.userId).toEqual('user-user');
expect(auth.isAdmin).toEqual(false);
});
tap.test('requireOpsAuth rejects non-admin JWT identity for admin identity requirements', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: 'user' }),
{ identity: makeIdentity('user') },
{ scope: 'routes:write', requireAdminIdentity: true },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('admin identity required');
});
tap.test('requireOpsAuth accepts scoped API tokens', async () => {
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
{ apiToken: 'valid-token' },
{ scope: 'logs:read' },
);
expect(auth.type).toEqual('apiToken');
expect(auth.userId).toEqual('token-user');
});
tap.test('requireOpsAuth rejects API tokens without the required scope', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
{ apiToken: 'valid-token' },
{ scope: 'stats:read' },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('insufficient scope');
});
tap.test('requireOpsAuth requires admin policy for sensitive API-token operations', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['tokens:manage'] }),
{ apiToken: 'valid-token' },
{ scope: 'tokens:manage', requireAdminToken: true },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('admin API token required');
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenPolicy: { role: 'admin' } }),
{ apiToken: 'valid-token' },
{ scope: 'tokens:manage', requireAdminToken: true },
);
expect(auth.isAdmin).toEqual(true);
});
export default tap.start();
+8 -2
View File
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3101,
@@ -25,11 +27,15 @@ tap.test('should login as admin', async () => {
const response = await loginRequest.fire({
username: 'admin',
password: 'admin',
password: testAdminPassword,
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
});
tap.test('should respond to health status request', async () => {
+8 -2
View File
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3103,
@@ -25,11 +27,15 @@ tap.test('should login as admin', async () => {
const response = await loginRequest.fire({
username: 'admin',
password: 'admin'
password: testAdminPassword
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
console.log('Admin logged in with JWT');
});
+200
View File
@@ -0,0 +1,200 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { DcRouterDb, IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../ts/db/index.js';
import { SecurityPolicyManager } from '../ts/security/index.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const rule of await SecurityBlockRuleDoc.findAll()) {
await rule.delete();
}
for (const record of await IpIntelligenceDoc.findAll()) {
await record.delete();
}
for (const event of await SecurityPolicyAuditDoc.findRecent(1000)) {
await event.delete();
}
};
const createIntelligenceResult = (asn: number) => ({
asn,
asnOrg: `ASN ${asn}`,
registrantOrg: null,
registrantCountry: null,
networkRange: null,
networkCidrs: null,
abuseContact: null,
country: null,
countryCode: 'US',
city: null,
latitude: null,
longitude: null,
accuracyRadius: null,
timezone: null,
});
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
await manager.createBlockRule({
type: 'cidr',
value: '203.0.113.0 - 203.0.113.255',
reason: 'test range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['203.0.113.0/24']);
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall.blockedIps).toEqual(['203.0.113.0/24']);
});
tap.test('SecurityPolicyManager compiles intelligence network ranges for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.23';
intelligenceDoc.asn = 64500;
intelligenceDoc.asnOrg = 'Example Network';
intelligenceDoc.networkRange = '198.51.100.0 - 198.51.100.127';
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64500',
reason: 'test asn range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.0/25']);
});
tap.test('SecurityPolicyManager compiles intelligence CIDR arrays for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.130';
intelligenceDoc.asn = 64501;
intelligenceDoc.asnOrg = 'Example Split Network';
intelligenceDoc.networkRange = null;
intelligenceDoc.networkCidrs = ['198.51.100.128/25', '198.51.101.0/24'];
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64501',
reason: 'test asn cidr array',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.128/25', '198.51.101.0/24']);
});
tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall).toEqual({ blockedIps: [] });
});
tap.test('SecurityPolicyManager filters listed IP intelligence records', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
for (const [ipAddress, asn] of [['8.8.8.8', 15169], ['1.1.1.1', 13335]] as const) {
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = ipAddress;
intelligenceDoc.asn = asn;
intelligenceDoc.asnOrg = `ASN ${asn}`;
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
}
const records = await manager.listIpIntelligence({ ipAddresses: ['1.1.1.1'] });
expect(records).toHaveLength(1);
expect(records[0].ipAddress).toEqual('1.1.1.1');
});
tap.test('SecurityPolicyManager force refresh waits for an in-flight background observation', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager({ intelligenceRefreshMs: 0 });
let releaseFirstLookup!: () => void;
let lookupCount = 0;
(manager as any).smartNetwork = {
getIpIntelligence: async () => {
lookupCount++;
if (lookupCount === 1) {
await new Promise<void>((resolve) => { releaseFirstLookup = resolve; });
return createIntelligenceResult(64500);
}
return createIntelligenceResult(64501);
},
stop: async () => {},
};
const backgroundObservation = manager.observeIp('8.8.8.8');
await new Promise((resolve) => setTimeout(resolve, 10));
const forcedRefresh = manager.refreshIpIntelligence('8.8.8.8');
releaseFirstLookup();
const record = await forcedRefresh;
await backgroundObservation;
expect(lookupCount).toEqual(2);
expect(record?.asn).toEqual(64501);
});
tap.test('cleanup security policy test db', async () => {
const dbHandle = await testDbPromise;
await clearTestState();
await dbHandle.cleanup();
});
export default tap.start();
@@ -0,0 +1,31 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { SmartMtaStorageManager } from '../ts/email/index.js';
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test-smartmta-storage');
tap.test('SmartMtaStorageManager persists, lists, and deletes keys', async () => {
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
const storageManager = new SmartMtaStorageManager(tempDir);
await storageManager.set('/email/dkim/example.com/default/metadata', 'metadata');
await storageManager.set('/email/dkim/example.com/default/public.key', 'public');
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toEqual('metadata');
const keys = await storageManager.list('/email/dkim/example.com/');
expect(keys).toEqual([
'/email/dkim/example.com/default/metadata',
'/email/dkim/example.com/default/public.key',
]);
await storageManager.delete('/email/dkim/example.com/default/metadata');
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toBeNull();
});
tap.test('cleanup', async () => {
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
await tap.stopForcefully();
});
export default tap.start();
+8 -2
View File
@@ -5,6 +5,7 @@ import * as interfaces from '../ts_interfaces/index.js';
const TEST_PORT = 3200;
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
const TEST_ADMIN_PASSWORD = 'test-admin-password';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
@@ -14,6 +15,7 @@ let adminIdentity: interfaces.data.IIdentity;
// ============================================================================
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
testDcRouter = new DcRouter({
opsServerPort: TEST_PORT,
dbConfig: { enabled: false },
@@ -31,11 +33,15 @@ tap.test('should login as admin', async () => {
const response = await loginRequest.fire({
username: 'admin',
password: 'admin',
password: TEST_ADMIN_PASSWORD,
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
});
// ============================================================================
+471
View File
@@ -0,0 +1,471 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
import { TargetProfileManager } from '../ts/config/classes.target-profile-manager.js';
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
(manager as any).forwardingModeOverride = undefined;
(manager as any).vpnServer = { running: true };
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(true);
expect(stopCalls).toEqual(1);
expect(startCalls).toEqual(1);
expect((manager as any).resolvedForwardingMode).toEqual('socket');
});
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
const manager = new VpnManager({ forwardingMode: 'hybrid' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(false);
expect(stopCalls).toEqual(0);
expect(startCalls).toEqual(0);
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
});
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
const dcRouter = new DcRouter({
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
vpnConfig: { enabled: false },
});
let stopCalls = 0;
let setupCalls = 0;
let applyCalls = 0;
const resolverValues: Array<unknown> = [];
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
(dcRouter as any).routeConfigManager = {
setVpnClientAccessResolver: (resolver: unknown) => {
resolverValues.push(resolver);
},
applyRoutes: async () => {
applyCalls++;
},
};
(dcRouter as any).setupVpnServer = async () => {
setupCalls++;
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
};
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
expect(stopCalls).toEqual(1);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(0);
expect(typeof resolverValues.at(-1)).toEqual('function');
await dcRouter.updateVpnConfig({ enabled: false });
expect(stopCalls).toEqual(2);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(1);
expect(resolverValues.at(-1)).toBeUndefined();
expect(dcRouter.vpnManager).toBeUndefined();
});
tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN clients', async () => {
const manager = new RouteConfigManager(() => undefined);
const route = {
name: 'private-route',
vpnOnly: true,
match: { domains: ['private.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: { ipAllowList: ['*'] },
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['*']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
});
tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['client-1'],
);
const route = {
name: 'private-route',
vpnOnly: true,
match: { domains: ['private.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: {
ipAllowList: ['*', '203.0.113.10'],
ipBlockList: ['198.51.100.5'],
},
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['*', '203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
});
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['client-1'],
);
const route = {
name: 'shared-private-route',
match: { domains: ['app.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: {
ipAllowList: ['203.0.113.10'],
ipBlockList: ['198.51.100.5'],
},
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
});
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'hagen.team VPN access',
domains: ['*.hagen.team'],
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'hagen.team VPN access',
domains: ['*.hagen.team'],
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('*.hagen.team');
expect(accessSpec.domains).toContain('app.hagen.team');
});
tap.test('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager leaves real source-IP enforcement to SmartProxy', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager does not grant routes with wildcard source block', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'blocked-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: {
ipAllowList: ['203.0.113.0/24'],
ipBlockList: ['*'],
},
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual([]);
});
tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'public-route',
match: { domains: 'public.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager grants vpnOnly routes through source-policy profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'vpn-only-route',
vpnOnly: true,
match: { domains: 'private.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'source-reachable-app',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.0/24'] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('app.example.com');
});
tap.test('VpnManager normalizes real remote addresses', async () => {
expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10');
expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1');
expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1');
});
tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => {
const manager = new VpnManager({});
let sourceIpChangeCalls = 0;
(manager as any).config.onClientSourceIpsChanged = () => {
sourceIpChangeCalls++;
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }],
]);
(manager as any).vpnServer = {
listClients: async () => ([
{
clientId: 'runtime-client-1',
registeredClientId: 'client-1',
assignedIp: '10.8.0.2',
transportType: 'wireguard',
},
]),
listWgPeers: async () => ([
{
publicKey: 'wg-public-key',
allowedIps: ['10.8.0.2/32'],
endpoint: '198.51.100.44:61234',
bytesSent: 0,
bytesReceived: 0,
packetsSent: 0,
packetsReceived: 0,
},
]),
};
const changed = await manager.refreshClientSourceIps();
const changedAgain = await manager.refreshClientSourceIps();
expect(changed).toEqual(true);
expect(changedAgain).toEqual(false);
expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44');
expect(sourceIpChangeCalls).toEqual(1);
});
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
const manager = new VpnManager({
serverEndpoint: 'vpn.example.com',
getClientAllowedIPs: async () => ['10.8.0.0/24', '203.0.113.10/32'],
});
(manager as any).vpnServer = {
rotateClientKey: async () => ({
entry: {
clientId: 'client-1',
publicKey: 'noise-public-key',
wgPublicKey: 'wg-public-key',
},
wireguardConfig: '[Interface]\nPrivateKey = old\nAddress = 10.8.0.2/24\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.com:51820\n',
secrets: { noisePrivateKey: 'noise-private-key', wgPrivateKey: 'wg-private-key' },
}),
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', targetProfileIds: ['profile-1'] }],
]);
(manager as any).persistClient = async () => {};
const bundle = await manager.rotateClientKey('client-1');
expect(bundle.wireguardConfig).toContain('AllowedIPs = 10.8.0.0/24, 203.0.113.10/32');
});
export default tap.start()
+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();
+565
View File
@@ -0,0 +1,565 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { WorkHosterHandler } from '../ts/opsserver/handlers/workhoster.handler.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeApiTokenManager = (
scopes: TScope[],
policy?: interfaces.data.IApiTokenPolicy,
) => {
const token = {
id: 'token-1',
name: 'workhoster-test-token',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
policy,
} as interfaces.data.IStoredApiToken;
return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => {
if (storedToken.policy?.role === 'admin') return true;
const isGatewayClientToken = storedToken.policy?.role === 'gatewayClient';
const gatewayClientAllowedScopes = new Set<TScope>([
'gateway-clients:read',
'gateway-clients:write',
'workhosters:read',
'workhosters:write',
]);
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) return false;
if (!isGatewayClientToken && storedToken.scopes.includes('*')) return true;
const scopes = new Set(storedToken.scopes);
for (const policyScope of storedToken.policy?.scopes || []) {
scopes.add(policyScope);
}
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
'gateway-clients:read': ['workhosters:read'],
'gateway-clients:write': ['workhosters:write'],
'workhosters:read': ['gateway-clients:read'],
'workhosters:write': ['gateway-clients:write'],
};
return scopes.has(scope) || Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
},
};
};
const makeRouteConfigManager = () => {
const routes = new Map<string, interfaces.data.IRoute>();
let nextRouteNumber = 1;
return {
routes,
manager: {
findApiRouteByExternalKey: (externalKey: string) => {
return Array.from(routes.values()).find((route) =>
route.origin === 'api' && route.metadata?.externalKey === externalKey,
);
},
createRoute: async (
route: interfaces.data.IDcRouterRouteConfig,
createdBy: string,
enabled = true,
metadata?: interfaces.data.IRouteMetadata,
) => {
const id = `route-${nextRouteNumber++}`;
routes.set(id, {
id,
route,
enabled,
createdBy,
createdAt: Date.now(),
updatedAt: Date.now(),
origin: 'api',
metadata,
});
return id;
},
updateRoute: async (
id: string,
patch: {
route?: Partial<interfaces.data.IDcRouterRouteConfig>;
enabled?: boolean;
metadata?: Partial<interfaces.data.IRouteMetadata>;
},
) => {
const storedRoute = routes.get(id);
if (!storedRoute) return { success: false, message: 'Route not found' };
if (patch.route) {
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
}
if (patch.enabled !== undefined) {
storedRoute.enabled = patch.enabled;
}
if (patch.metadata) {
storedRoute.metadata = { ...storedRoute.metadata, ...patch.metadata };
}
storedRoute.updatedAt = Date.now();
return { success: true };
},
deleteRoute: async (id: string) => {
const deleted = routes.delete(id);
return deleted ? { success: true } : { success: false, message: 'Route not found' };
},
},
};
};
const setupHandler = (options: {
scopes: TScope[];
policy?: interfaces.data.IApiTokenPolicy;
isAdmin?: boolean;
dcRouterRef?: Record<string, any>;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = {
typedrouter,
adminHandler: {
validateIdentity: async (identity: interfaces.data.IIdentity) => options.isAdmin
? { ...identity, role: 'admin' }
: identity,
adminIdentityGuard: {
exec: async () => Boolean(options.isAdmin),
},
},
dcRouterRef: {
options: {},
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
...options.dcRouterRef,
},
};
new WorkHosterHandler(opsServerRef);
return { typedrouter, opsServerRef };
};
tap.test('WorkHosterHandler exposes capabilities and managed domains with workhosters:read', async () => {
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {
remoteIngressConfig: { enabled: true },
dnsScopes: ['example.com'],
http3: { enabled: false },
},
routeConfigManager: {
getMergedRoutes: () => ({ routes: [] }),
},
smartProxy: {},
emailDomainManager: {},
emailServer: {},
dnsManager: {
listDomains: async () => [
{ id: 'domain-1', name: 'example.com', source: 'dcrouter', authoritative: true },
{ id: 'domain-2', name: 'provider.example', source: 'provider', providerId: 'cloudflare-1', authoritative: false },
],
toPublicDomain: (domainDoc: any) => ({
...domainDoc,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
}),
},
},
});
const capabilitiesResult = await fireTypedRequest(typedrouter, 'getGatewayCapabilities', {
apiToken: 'valid-token',
});
expect(capabilitiesResult.error).toBeUndefined();
expect(capabilitiesResult.response.capabilities.routes.idempotentSync).toEqual(true);
expect(capabilitiesResult.response.capabilities.domains.read).toEqual(true);
expect(capabilitiesResult.response.capabilities.certificates.export).toEqual(true);
expect(capabilitiesResult.response.capabilities.email.inbound).toEqual(true);
expect(capabilitiesResult.response.capabilities.remoteIngress.enabled).toEqual(true);
expect(capabilitiesResult.response.capabilities.dns.authoritative).toEqual(true);
expect(capabilitiesResult.response.capabilities.http3.enabled).toEqual(false);
const domainsResult = await fireTypedRequest(typedrouter, 'getWorkHosterDomains', {
apiToken: 'valid-token',
});
expect(domainsResult.error).toBeUndefined();
expect(domainsResult.response.domains.length).toEqual(2);
expect(domainsResult.response.domains[0].capabilities.canCreateSubdomains).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canManageDnsRecords).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canIssueCertificates).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canHostEmail).toEqual(true);
});
tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:write', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['workhosters:write'],
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const ownership: interfaces.data.IWorkAppRouteOwnership = {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
hostname: 'app.example.com',
};
const createResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
route: {
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.2', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(createResult.error).toBeUndefined();
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(1);
const createdRoute = routeConfig.routes.get('route-1')!;
expect(createdRoute.createdBy).toEqual('token-user');
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
expect(createdRoute.metadata).toEqual({
ownerType: 'gatewayClient',
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
gatewayClientAppId: 'app-1',
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
});
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
enabled: false,
route: {
name: 'updated-workapp-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.3', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(updateResult.error).toBeUndefined();
expect(updateResult.response).toEqual({ success: true, action: 'updated', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(1);
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
delete: true,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response).toEqual({ success: true, action: 'deleted', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(0);
const unchangedResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
delete: true,
});
expect(unchangedResult.error).toBeUndefined();
expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' });
});
tap.test('WorkHosterHandler exposes gateway client context for token-bound clients', async () => {
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
hostnamePatterns: ['*.example.com'],
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
capabilities: {
readDomains: true,
readDnsRecords: true,
syncRoutes: true,
},
},
dcRouterRef: { options: {} },
});
const result = await fireTypedRequest(typedrouter, 'getGatewayClientContext', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.context.gatewayClient).toEqual({ type: 'onebox', id: 'box-policy' });
expect(result.response.context.hostnamePatterns).toEqual(['*.example.com']);
expect(result.response.context.capabilities.syncRoutes).toEqual(true);
});
tap.test('WorkHosterHandler derives route ownership from gateway client token policy', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:write'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
hostnamePatterns: ['*.example.com'],
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
capabilities: { syncRoutes: true },
},
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const createResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
apiToken: 'valid-token',
ownership: {
appId: 'app-1',
hostname: 'app.example.com',
},
route: {
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.2', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(createResult.error).toBeUndefined();
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
expect(routeConfig.routes.get('route-1')?.metadata?.gatewayClientId).toEqual('box-policy');
expect(routeConfig.routes.get('route-1')?.metadata?.externalKey).toEqual('onebox:box-policy:app-1:app.example.com');
const spoofResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
apiToken: 'valid-token',
ownership: {
gatewayClientType: 'onebox',
gatewayClientId: 'other-box',
appId: 'app-1',
hostname: 'app.example.com',
},
delete: true,
});
expect(spoofResult.error?.text).toEqual('gateway client token cannot act for this ownership');
});
tap.test('WorkHosterHandler manages durable gateway clients and creates scoped tokens', async () => {
const identity: interfaces.data.IIdentity = {
jwt: 'admin-jwt',
userId: 'admin-user',
name: 'admin',
expiresAt: Date.now() + 3600000,
};
const gatewayClient: interfaces.data.IGatewayClient = {
id: 'onebox-main',
type: 'onebox',
name: 'Main Onebox',
hostnamePatterns: ['*.apps.example.com'],
allowedRouteTargets: [{ host: 'onebox-smartproxy', ports: [80] }],
capabilities: { readDomains: true, readDnsRecords: true, syncRoutes: true },
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'admin-user',
};
let createdTokenPolicy: interfaces.data.IApiTokenPolicy | undefined;
const { typedrouter } = setupHandler({
scopes: [],
isAdmin: true,
dcRouterRef: {
options: {},
gatewayClientManager: {
listClients: async () => [gatewayClient],
getClient: async (id: string) => id === gatewayClient.id ? gatewayClient : null,
},
apiTokenManager: {
listTokens: () => [{
id: 'token-1',
name: 'token',
scopes: ['gateway-clients:read'],
policy: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-main' } },
createdAt: 1,
expiresAt: null,
lastUsedAt: null,
enabled: true,
}],
createToken: async (
_name: string,
_scopes: TScope[],
_expiresInDays: number | null,
_createdBy: string,
policy?: interfaces.data.IApiTokenPolicy,
) => {
createdTokenPolicy = policy;
return { id: 'new-token', rawToken: 'dcr_created' };
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listGatewayClients', { identity });
expect(listResult.error).toBeUndefined();
expect(listResult.response.gatewayClients[0].tokenCount).toEqual(1);
const tokenResult = await fireTypedRequest(typedrouter, 'createGatewayClientToken', {
identity,
gatewayClientId: 'onebox-main',
});
expect(tokenResult.error).toBeUndefined();
expect(tokenResult.response.tokenValue).toEqual('dcr_created');
expect(createdTokenPolicy?.gatewayClient).toEqual({ type: 'onebox', id: 'onebox-main' });
expect(createdTokenPolicy?.allowedRouteTargets).toEqual([{ host: 'onebox-smartproxy', ports: [80] }]);
});
tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const result = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
hostname: 'app.example.com',
},
delete: true,
});
expect(result.error?.text).toEqual('insufficient scope');
expect(routeConfig.routes.size).toEqual(0);
});
tap.test('WorkHosterHandler exposes and syncs WorkApp mail identities', async () => {
const syncedRequests: Array<{ data: any; userId: string }> = [];
const identity: interfaces.data.IWorkAppMailIdentity = {
id: 'mail-1',
externalKey: 'onebox:box-1:app-1:hello@example.com',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
inbound: {
enabled: true,
targetHost: '10.0.0.2',
targetPort: 2525,
},
smtp: {
enabled: true,
username: 'workapp-user',
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['workhosters:read', 'workhosters:write'],
dcRouterRef: {
options: {},
workAppMailManager: {
listMailIdentities: async (filter: any) => filter.workAppId === 'app-1' ? [identity] : [],
syncMailIdentity: async (data: any, userId: string) => {
syncedRequests.push({ data, userId });
return {
success: true,
action: 'created',
identity,
smtpCredentials: {
username: 'workapp-user',
password: 'generated-password',
},
};
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'getWorkAppMailIdentities', {
apiToken: 'valid-token',
ownership: { workAppId: 'app-1' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.identities).toEqual([identity]);
const syncResult = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
apiToken: 'valid-token',
ownership: identity.ownership,
localPart: 'hello',
domain: 'example.com',
inbound: identity.inbound,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncResult.response.smtpCredentials.password).toEqual('generated-password');
expect(syncedRequests[0].userId).toEqual('token-user');
});
tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write', async () => {
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {},
workAppMailManager: {
syncMailIdentity: async () => ({ success: true }),
},
},
});
const result = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
apiToken: 'valid-token',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
localPart: 'hello',
domain: 'example.com',
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+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.16.2',
version: '13.37.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+417 -173
View File
@@ -9,6 +9,7 @@ import {
type IUnifiedEmailServerOptions,
type IEmailRoute,
type IEmailDomainConfig,
type IStorageManagerLike,
} from '@push.rocks/smartmta';
import { logger } from './logger.js';
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
@@ -24,12 +25,15 @@ import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
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 } from './email/classes.email-domain.manager.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';
export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -163,6 +167,14 @@ export interface IDcRouterOptions {
/** Port for the OpsServer web UI (default: 3000) */
opsServerPort?: number;
/** Optional OpsServer account authentication settings. */
adminAuth?: {
/** Optional idp.global password-authentication URL override. Defaults to the SDK's hosted https://idp.global endpoint. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
idpGlobalUrl?: string;
/** Test/integration hook for injecting an idp.global-compatible password client. */
idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
};
remoteIngressConfig?: {
/** Enable remote ingress hub (default: false) */
enabled?: boolean;
@@ -175,6 +187,8 @@ export interface IDcRouterOptions {
certPath?: string;
keyPath?: string;
};
/** Performance profile and limits for remote ingress hub/edge tunnels. */
performance?: import('../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
};
/**
@@ -248,15 +262,13 @@ export class DcRouter {
public radiusServer?: RadiusServer;
public opsServer!: OpsServer;
public metricsManager?: MetricsManager;
private emailEventSubscriptions: Array<{
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
eventName: string;
listener: (...args: any[]) => void;
}> = [];
// Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set()
public storageManager: any = {
get: async (_key: string) => null,
set: async (_key: string, _value: string) => {
// DKIM keys from smartmta — logged but not yet migrated to smartdata
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
},
};
public storageManager: IStorageManagerLike;
// Unified database (smartdata + LocalSmartDb or external MongoDB)
public dcRouterDb?: DcRouterDb;
@@ -272,6 +284,7 @@ export class DcRouter {
// Programmatic config API
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
public gatewayClientManager?: GatewayClientManager;
public referenceResolver?: ReferenceResolver;
public targetProfileManager?: TargetProfileManager;
@@ -281,6 +294,8 @@ 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)
public detectedPublicIp: string | null = null;
@@ -316,6 +331,8 @@ export class DcRouter {
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -328,6 +345,11 @@ export class DcRouter {
// Resolve all data paths from baseDir
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
paths.ensureDataDirectories(this.resolvedPaths);
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({
@@ -451,19 +473,47 @@ export class DcRouter {
.dependsOn('DcRouterDb')
.withStart(async () => {
this.emailDomainManager = new EmailDomainManager(this);
await this.emailDomainManager.start();
})
.withStop(async () => {
this.emailDomainManager = undefined;
if (this.emailDomainManager) {
await this.emailDomainManager.stop();
this.emailDomainManager = undefined;
}
}),
);
}
// SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence
// and compiles the global block policy for SmartProxy and remote ingress edges.
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('SecurityPolicyManager')
.optional()
.dependsOn('DcRouterDb')
.withStart(async () => {
this.securityPolicyManager = new SecurityPolicyManager({
onPolicyChanged: () => this.applySecurityPolicy(),
});
await this.securityPolicyManager.start();
})
.withStop(async () => {
if (this.securityPolicyManager) {
await this.securityPolicyManager.stop();
this.securityPolicyManager = undefined;
}
})
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
);
}
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
const smartProxyDeps: string[] = [];
if (this.options.dbConfig?.enabled !== false) {
smartProxyDeps.push('DcRouterDb');
smartProxyDeps.push('DnsManager');
smartProxyDeps.push('AcmeConfigManager');
smartProxyDeps.push('SecurityPolicyManager');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartProxy')
@@ -547,42 +597,43 @@ export class DcRouter {
await this.referenceResolver.initialize();
// Initialize target profile manager
this.targetProfileManager = new TargetProfileManager();
this.targetProfileManager = new TargetProfileManager(
() => this.routeConfigManager?.getRoutes() || new Map(),
);
await this.targetProfileManager.initialize();
this.routeConfigManager = new RouteConfigManager(
() => this.smartProxy,
() => this.options.http3,
this.options.vpnConfig?.enabled
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
if (!this.vpnManager || !this.targetProfileManager) {
// VPN not ready yet — deny all until re-apply after VPN starts
return [];
}
return this.targetProfileManager.getMatchingClientIps(
route, routeId, this.vpnManager.listClients(),
);
}
: undefined,
this.createVpnClientAccessResolver(),
this.referenceResolver,
// Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary
(routes) => {
async (routes) => {
if (this.remoteIngressManager) {
this.remoteIngressManager.setRoutes(routes as any[]);
}
if (this.tunnelManager) {
this.tunnelManager.syncAllowedEdges();
try {
await this.tunnelManager.syncAllowedEdges();
} catch (err: unknown) {
logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
}
}
},
undefined,
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
);
this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize();
this.gatewayClientManager = new GatewayClientManager();
await this.gatewayClientManager.initialize();
await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
await this.targetProfileManager.normalizeAllRouteRefs();
// Seed default profiles/targets if DB is empty and seeding is enabled
const seeder = new DbSeeder(this.referenceResolver);
@@ -594,6 +645,7 @@ export class DcRouter {
.withStop(async () => {
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
this.gatewayClientManager = undefined;
this.referenceResolver = undefined;
this.targetProfileManager = undefined;
})
@@ -603,19 +655,20 @@ export class DcRouter {
// Email Server: optional, depends on SmartProxy
if (this.options.emailConfig) {
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
if (this.options.dbConfig?.enabled !== false) {
emailServiceDeps.push('EmailDomainManager');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('EmailServer')
.optional()
.dependsOn('SmartProxy')
.dependsOn(...emailServiceDeps)
.withStart(async () => {
await this.setupUnifiedEmailHandling();
})
.withStop(async () => {
if (this.emailServer) {
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
this.clearEmailEventSubscriptions();
await this.emailServer.stop();
this.emailServer = undefined;
}
@@ -629,7 +682,7 @@ export class DcRouter {
this.serviceManager.addService(
new plugins.taskbuffer.Service('DnsServer')
.optional()
.dependsOn('SmartProxy')
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
.withStart(async () => {
await this.setupDnsWithSocketHandler();
})
@@ -695,10 +748,14 @@ export class DcRouter {
// VPN Server: optional, depends on SmartProxy
if (this.options.vpnConfig?.enabled) {
const vpnServiceDeps = ['SmartProxy'];
if (this.options.dbConfig?.enabled !== false) {
vpnServiceDeps.push('ConfigManagers');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('VpnServer')
.optional()
.dependsOn('SmartProxy')
.dependsOn(...vpnServiceDeps)
.withStart(async () => {
await this.setupVpnServer();
})
@@ -886,7 +943,7 @@ export class DcRouter {
this.smartProxy = undefined;
}
// Assemble seed routes from constructor config — these will be seeded into DB
// Assemble serializable seed routes from constructor config — these will be seeded into DB
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
@@ -898,8 +955,10 @@ export class DcRouter {
}
this.seedDnsRoutes = [];
this.runtimeDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
this.seedDnsRoutes = this.generateDnsRoutes();
this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false });
this.runtimeDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: true });
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
}
@@ -907,7 +966,7 @@ export class DcRouter {
let routes: plugins.smartproxy.IRouteConfig[] = [
...this.seedConfigRoutes,
...this.seedEmailRoutes,
...this.seedDnsRoutes,
...this.runtimeDnsRoutes,
];
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
@@ -956,6 +1015,12 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
}
const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy();
const mergedSecurityPolicy = this.mergeSecurityPolicies(
(this.options.smartProxyConfig as any)?.securityPolicy,
compiledSecurityPolicy,
);
// If we have routes or need a basic SmartProxy instance, create it
if (routes.length > 0 || this.options.smartProxyConfig) {
logger.log('info', 'Setting up SmartProxy with combined configuration');
@@ -987,6 +1052,7 @@ export class DcRouter {
// --- always set by dcrouter (after spread) ---
routes,
acme: acmeConfig,
...(mergedSecurityPolicy ? { securityPolicy: mergedSecurityPolicy } as any : {}),
certStore: {
loadAll: async () => {
const docs = await ProxyCertDoc.findAll();
@@ -1051,6 +1117,7 @@ export class DcRouter {
});
const scheduler = this.certProvisionScheduler;
smartProxyConfig.certProvisionFallbackToAcme = false;
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
// If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01
if (!this.smartAcmeReady) {
@@ -1099,10 +1166,10 @@ export class DcRouter {
await scheduler.clearBackoff(domain);
return result;
} catch (err: unknown) {
// Record failure for backoff tracking
await scheduler.recordFailure(domain, (err as Error).message);
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`);
return 'http01';
const message = `DNS-01 failed for ${domain}: ${(err as Error).message}`;
await scheduler.recordFailure(domain, message);
eventComms.warn(message);
throw new Error(message);
}
};
}
@@ -1111,7 +1178,12 @@ export class DcRouter {
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
if (this.options.remoteIngressConfig?.enabled) {
smartProxyConfig.acceptProxyProtocol = true;
smartProxyConfig.proxyIPs = ['127.0.0.1'];
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
}
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
@@ -1224,8 +1296,60 @@ export class DcRouter {
logger.log('info', `SmartProxy started with ${routes.length} routes`);
}
}
public async applySecurityPolicy(): Promise<void> {
if (!this.securityPolicyManager) {
return;
}
const compiledSmartProxyPolicy = await this.securityPolicyManager.compileSmartProxyPolicy();
const mergedSecurityPolicy = this.mergeSecurityPolicies(
(this.options.smartProxyConfig as any)?.securityPolicy,
compiledSmartProxyPolicy,
);
if (this.smartProxy && mergedSecurityPolicy) {
const smartProxyWithPolicyApi = this.smartProxy as any;
if (typeof smartProxyWithPolicyApi.updateSecurityPolicy === 'function') {
await smartProxyWithPolicyApi.updateSecurityPolicy(mergedSecurityPolicy);
}
}
const firewallConfig = await this.securityPolicyManager.compileRemoteIngressFirewall();
if (this.remoteIngressManager) {
(this.remoteIngressManager as any).setFirewallConfig?.(firewallConfig);
}
if (this.tunnelManager) {
await this.tunnelManager.syncAllowedEdges();
}
}
private mergeSecurityPolicies(
...policies: Array<Partial<ISecurityCompiledPolicy> | undefined>
): ISecurityCompiledPolicy | undefined {
const blockedIps = new Set<string>();
const blockedCidrs = new Set<string>();
for (const policy of policies) {
for (const ip of policy?.blockedIps || []) {
if (ip) blockedIps.add(ip);
}
for (const cidr of policy?.blockedCidrs || []) {
if (cidr) blockedCidrs.add(cidr);
}
}
if (blockedIps.size === 0 && blockedCidrs.size === 0) {
return undefined;
}
return {
blockedIps: [...blockedIps].sort(),
blockedCidrs: [...blockedCidrs].sort(),
};
}
/**
* Generate SmartProxy routes for email configuration
@@ -1323,19 +1447,20 @@ export class DcRouter {
/**
* Generate SmartProxy routes for DNS configuration
*/
private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] {
private generateDnsRoutes(options?: { includeSocketHandler?: boolean }): plugins.smartproxy.IRouteConfig[] {
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
return [];
}
const includeSocketHandler = options?.includeSocketHandler !== false;
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Create routes for DNS-over-HTTPS paths
const dohPaths = ['/dns-query', '/resolve'];
// Use the first nameserver domain for DoH routes
const primaryNameserver = this.options.dnsNsDomains[0];
for (const path of dohPaths) {
const dohRoute: plugins.smartproxy.IRouteConfig = {
name: `dns-over-https-${path.replace('/', '')}`,
@@ -1344,18 +1469,42 @@ export class DcRouter {
domains: [primaryNameserver],
path: path
},
action: {
type: 'socket-handler' as any,
socketHandler: this.createDnsSocketHandler()
} as any
action: includeSocketHandler
? {
type: 'socket-handler' as any,
socketHandler: this.createDnsSocketHandler()
} as any
: {
type: 'socket-handler' as any,
} as any
};
dnsRoutes.push(dohRoute);
}
return dnsRoutes;
}
private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
const routeName = storedRoute.route.name || '';
const isDohRoute = storedRoute.origin === 'dns'
&& storedRoute.route.action?.type === 'socket-handler'
&& routeName.startsWith('dns-over-https-');
if (!isDohRoute) {
return undefined;
}
return {
...storedRoute.route,
action: {
...storedRoute.route.action,
type: 'socket-handler' as any,
socketHandler: this.createDnsSocketHandler(),
} as any,
};
}
/**
* Check if a domain matches a pattern (including wildcard support)
* @param domain The domain to check
@@ -1457,7 +1606,6 @@ export class DcRouter {
await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
}
@@ -1501,44 +1649,78 @@ 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),
hostname: 'localhost' // Listen on localhost for SmartProxy forwarding
};
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
queue: {
storageType: 'disk',
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
...this.options.emailConfig.queue,
},
});
// Create unified email server
this.emailServer = new UnifiedEmailServer(this, emailConfig);
this.clearEmailEventSubscriptions();
// Set up error handling
this.emailServer.on('error', (err: Error) => {
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
});
// Start the server
await this.emailServer.start();
// Wire delivery events to MetricsManager and logger
if (this.metricsManager && this.emailServer.deliverySystem) {
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
this.metricsManager!.trackEmailReceived(item?.from);
logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' });
});
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
this.metricsManager!.trackEmailSent(item?.to);
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
});
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
this.metricsManager!.trackEmailFailed(item?.to, error?.message);
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
});
}
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
if (this.metricsManager && this.emailServer) {
this.emailServer.on('bounceProcessed', () => {
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
const emailLike = item?.processingResult;
const from = emailLike?.from || emailLike?.email?.from || '';
const recipients = Array.isArray(emailLike?.to)
? emailLike.to
: Array.isArray(emailLike?.email?.to)
? emailLike.email.to
: [];
return {
from,
recipients: recipients.filter(Boolean),
};
};
const updateQueueSize = () => {
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
};
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailReceived(envelope.from);
updateQueueSize();
logger.log('info', `Email queued: ${envelope.from}${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
updateQueueSize();
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
updateQueueSize();
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
updateQueueSize();
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
updateQueueSize();
});
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
this.metricsManager!.trackEmailBounced();
logger.log('warn', 'Email bounce processed', { zone: 'email' });
});
updateQueueSize();
}
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
@@ -1568,11 +1750,7 @@ export class DcRouter {
try {
// Stop the unified email server which contains all components
if (this.emailServer) {
// Remove listeners before stopping to prevent leaks on config update cycles
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
this.clearEmailEventSubscriptions();
await this.emailServer.stop();
logger.log('info', 'Unified email server stopped');
this.emailServer = undefined;
@@ -1777,14 +1955,14 @@ export class DcRouter {
// Generate and register authoritative records
const authoritativeRecords = await this.generateAuthoritativeRecords();
// Generate email DNS records
const emailDnsRecords = await this.generateEmailDnsRecords();
// Initialize DKIM for all email domains
await this.initializeDkimForEmailDomains();
// Load DKIM records from JSON files (they should now exist)
const dkimRecords = await this.loadDkimRecords();
// Generate email DNS records
const emailDnsRecords = await this.generateEmailDnsRecords();
// Ensure DKIM keys exist for internal-dns domains before generating records.
await this.initializeDkimForEmailDomains();
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
const dkimRecords = await this.loadDkimRecords();
// Combine all records: authoritative, email, DKIM, and user-defined
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
@@ -1895,37 +2073,20 @@ export class DcRouter {
for (const domainConfig of internalDnsDomains) {
const domain = domainConfig.domain;
const ttl = domainConfig.dns?.internal?.ttl || 3600;
const mxPriority = domainConfig.dns?.internal?.mxPriority || 10;
// MX record - points to the domain itself for email handling
records.push({
name: domain,
type: 'MX',
value: `${mxPriority} ${domain}`,
ttl
});
// SPF record - using sensible defaults
const spfRecord = 'v=spf1 a mx ~all';
records.push({
name: domain,
type: 'TXT',
value: spfRecord,
ttl
});
// DMARC record - using sensible defaults
const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring
const dmarcEmail = `dmarc@${domain}`;
records.push({
name: `_dmarc.${domain}`,
type: 'TXT',
value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`,
ttl
});
// Note: DKIM records will be generated later when DKIM keys are available
// They require the DKIMCreator which is part of the email server
const requiredRecords = buildEmailDnsRecords({
domain,
hostname: this.options.emailConfig.hostname,
mxPriority: domainConfig.dns?.internal?.mxPriority,
}).filter((record) => !record.name.includes('._domainkey.'));
for (const record of requiredRecords) {
records.push({
name: record.name,
type: record.type,
value: record.value,
ttl,
});
}
}
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
@@ -1933,54 +2094,30 @@ export class DcRouter {
}
/**
* Load DKIM records from JSON files
* Reads all *.dkimrecord.json files from the DNS records directory
* Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
*/
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
try {
// Ensure paths are imported
const dnsDir = this.resolvedPaths.dnsRecordsDir;
// Check if directory exists
if (!plugins.fs.existsSync(dnsDir)) {
logger.log('debug', 'No DNS records directory found, skipping DKIM record loading');
return records;
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
return records;
}
for (const domainConfig of this.options.emailConfig.domains) {
if (domainConfig.dnsMode !== 'internal-dns') {
continue;
}
// Read all files in the directory
const files = plugins.fs.readdirSync(dnsDir);
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
// Load each DKIM record
for (const file of dkimFiles) {
try {
const filePath = plugins.path.join(dnsDir, file);
const fileContent = plugins.fs.readFileSync(filePath, 'utf8');
const dkimRecord = JSON.parse(fileContent);
// Validate record structure
if (dkimRecord.name && dkimRecord.type === 'TXT' && dkimRecord.value) {
records.push({
name: dkimRecord.name,
type: 'TXT',
value: dkimRecord.value,
ttl: 3600 // Standard DKIM TTL
});
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
} else {
logger.log('warn', `Invalid DKIM record structure in ${file}`);
}
} catch (error: unknown) {
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
}
const selector = domainConfig.dkim?.selector || 'default';
try {
const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
records.push({
name: dkimRecord.name,
type: 'TXT',
value: dkimRecord.value,
ttl: domainConfig.dns?.internal?.ttl || 3600,
});
} catch (error: unknown) {
logger.log('error', `Failed to generate DKIM record for ${domainConfig.domain}: ${(error as Error).message}`);
}
} catch (error: unknown) {
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
}
return records;
@@ -2007,12 +2144,17 @@ export class DcRouter {
// Ensure necessary directories exist
paths.ensureDataDirectories(this.resolvedPaths);
// Generate DKIM keys for each email domain
// Generate DKIM keys for each internal-dns email domain using the configured selector.
for (const domainConfig of this.options.emailConfig.domains) {
if (domainConfig.dnsMode !== 'internal-dns') {
continue;
}
try {
// Generate DKIM keys for all domains, regardless of DNS mode
// This ensures keys are ready even if DNS mode changes later
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
await dkimCreator.handleDKIMKeysForSelector(
domainConfig.domain,
domainConfig.dkim?.selector || 'default',
domainConfig.dkim?.keySize || 2048,
);
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
} catch (error: unknown) {
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
@@ -2142,6 +2284,25 @@ export class DcRouter {
}
}
}
private addEmailEventSubscription(
emitter: {
on(eventName: string, listener: (...args: any[]) => void): void;
off(eventName: string, listener: (...args: any[]) => void): void;
},
eventName: string,
listener: (...args: any[]) => void,
): void {
emitter.on(eventName, listener);
this.emailEventSubscriptions.push({ emitter, eventName, listener });
}
private clearEmailEventSubscriptions(): void {
for (const subscription of this.emailEventSubscriptions) {
subscription.emitter.off(subscription.eventName, subscription.listener);
}
this.emailEventSubscriptions = [];
}
/**
* Detect the server's public IP address
@@ -2175,11 +2336,14 @@ export class DcRouter {
// Initialize the edge registration manager
this.remoteIngressManager = new RemoteIngressManager();
await this.remoteIngressManager.initialize();
this.remoteIngressManager.setFirewallConfig(
await this.securityPolicyManager?.compileRemoteIngressFirewall(),
);
// Pass current bootstrap routes so the manager can derive edge ports initially.
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
// will push the complete merged routes here.
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.seedDnsRoutes];
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
// If ConfigManagers finished before us, re-apply routes
@@ -2224,6 +2388,7 @@ export class DcRouter {
tunnelPort: riCfg.tunnelPort ?? 8443,
targetHost: '127.0.0.1',
tls: tlsConfig,
performance: riCfg.performance,
});
await this.tunnelManager.start();
@@ -2234,11 +2399,44 @@ export class DcRouter {
/**
* Set up VPN server for VPN-based route access control.
*/
private createVpnClientAccessResolver(): ((
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string,
) => TVpnClientAllowEntry[]) | undefined {
if (!this.options.vpnConfig?.enabled) {
return undefined;
}
return (
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string,
) => {
if (!this.vpnManager || !this.targetProfileManager) {
// VPN not ready yet — deny all until re-apply after VPN starts.
return [];
}
return this.targetProfileManager.getMatchingVpnClients(
route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
);
};
}
private async setupVpnServer(): Promise<void> {
if (!this.options.vpnConfig?.enabled) {
return;
}
if (this.options.dbConfig?.enabled === false) {
throw new Error('VPN requires dbConfig.enabled because clients, keys, routes, and target profiles are persisted in DcRouterDb');
}
if (!this.routeConfigManager || !this.targetProfileManager) {
throw new Error('VPN requires initialized route and target profile managers');
}
logger.log('info', 'Setting up VPN server...');
this.vpnManager = new VpnManager({
@@ -2254,17 +2452,21 @@ export class DcRouter {
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
onClientChanged: () => {
// Re-apply routes so profile-based ipAllowLists get updated
// Re-apply routes so profile-based VPN client grants get updated
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
this.routeConfigManager?.applyRoutes().catch((err) => {
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
});
},
onClientSourceIpsChanged: () => {
// SmartProxy now receives the real source IP per connection via PROXY v2.
// Source-IP changes are reflected in status/UI only; route config is static.
},
getClientDirectTargets: (targetProfileIds: string[]) => {
if (!this.targetProfileManager) return [];
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
},
getClientAllowedIPs: async (targetProfileIds: string[]) => {
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, _sourceIp?: string) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]);
@@ -2273,7 +2475,8 @@ export class DcRouter {
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, allRoutes,
targetProfileIds,
allRoutes,
);
// Add target IPs directly
@@ -2283,8 +2486,11 @@ export class DcRouter {
// Resolve DNS A records for matched domains (with caching)
for (const domain of domains) {
const stripped = domain.replace(/^\*\./, '');
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
if (this.isWildcardVpnDomain(domain)) {
this.logSkippedWildcardAllowedIp(domain);
continue;
}
const resolvedIps = await this.resolveVpnDomainIPs(domain);
for (const ip of resolvedIps) {
ips.add(`${ip}/32`);
}
@@ -2297,12 +2503,14 @@ export class DcRouter {
await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
// get correct profile-based ipAllowLists
// get correct profile-based VPN client grants.
await this.routeConfigManager?.applyRoutes();
}
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
private warnedWildcardVpnDomains = new Set<string>();
/**
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
@@ -2328,6 +2536,19 @@ export class DcRouter {
}
}
private isWildcardVpnDomain(domain: string): boolean {
return domain.includes('*');
}
private logSkippedWildcardAllowedIp(domain: string): void {
if (this.warnedWildcardVpnDomains.has(domain)) return;
this.warnedWildcardVpnDomains.add(domain);
logger.log(
'warn',
`VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
);
}
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
// via the getVpnAllowList callback — no longer a separate method here.
@@ -2365,6 +2586,29 @@ export class DcRouter {
logger.log('info', 'RADIUS configuration updated');
}
/**
* Update VPN configuration at runtime.
*/
public async updateVpnConfig(config: IDcRouterOptions['vpnConfig']): Promise<void> {
if (this.vpnManager) {
await this.vpnManager.stop();
this.vpnManager = undefined;
}
this.options.vpnConfig = config;
this.vpnDomainIpCache.clear();
this.warnedWildcardVpnDomains.clear();
this.routeConfigManager?.setVpnClientAccessResolver(this.createVpnClientAccessResolver());
if (this.options.vpnConfig?.enabled) {
await this.setupVpnServer();
} else {
await this.routeConfigManager?.applyRoutes();
}
logger.log('info', 'VPN configuration updated');
}
}
// Re-export email server types for convenience
+73 -4
View File
@@ -2,12 +2,15 @@ import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { ApiTokenDoc } from '../db/index.js';
import type {
IApiTokenPolicy,
IStoredApiToken,
IApiTokenInfo,
TApiTokenScope,
} from '../../ts_interfaces/data/route-management.js';
const TOKEN_PREFIX_STR = 'dcr_';
const ENV_ADMIN_TOKEN_ID = 'env-admin-token';
const ENV_ADMIN_TOKEN_CREATED_BY = 'dcrouter-env';
export class ApiTokenManager {
private tokens = new Map<string, IStoredApiToken>();
@@ -16,6 +19,7 @@ export class ApiTokenManager {
public async initialize(): Promise<void> {
await this.loadTokens();
await this.ensureEnvAdminToken();
if (this.tokens.size > 0) {
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
}
@@ -33,13 +37,14 @@ export class ApiTokenManager {
scopes: TApiTokenScope[],
expiresInDays: number | null,
createdBy: string,
policy?: IApiTokenPolicy,
): Promise<{ id: string; rawToken: string }> {
const id = plugins.uuid.v4();
const randomBytes = plugins.crypto.randomBytes(32);
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
const tokenHash = this.hashToken(rawToken);
const now = Date.now();
const stored: IStoredApiToken = {
@@ -47,6 +52,7 @@ export class ApiTokenManager {
name,
tokenHash,
scopes,
policy,
createdAt: now,
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
lastUsedAt: null,
@@ -67,7 +73,7 @@ export class ApiTokenManager {
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
const hash = this.hashToken(rawToken);
for (const stored of this.tokens.values()) {
if (stored.tokenHash === hash) {
@@ -87,7 +93,31 @@ export class ApiTokenManager {
* Check if a token has a specific scope.
*/
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
return token.scopes.includes(scope);
if (token.policy?.role === 'admin') return true;
const isGatewayClientToken = token.policy?.role === 'gatewayClient';
const gatewayClientAllowedScopes = new Set<TApiTokenScope>([
'gateway-clients:read',
'gateway-clients:write',
'workhosters:read',
'workhosters:write',
]);
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) {
return false;
}
if (!isGatewayClientToken && token.scopes.includes('*')) return true;
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
if (scopes.has(scope)) return true;
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
'gateway-clients:read': ['workhosters:read'],
'gateway-clients:write': ['workhosters:write'],
'workhosters:read': ['gateway-clients:read'],
'workhosters:write': ['gateway-clients:write'],
};
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
}
/**
@@ -100,6 +130,7 @@ export class ApiTokenManager {
id: stored.id,
name: stored.name,
scopes: stored.scopes,
policy: stored.policy,
createdAt: stored.createdAt,
expiresAt: stored.expiresAt,
lastUsedAt: stored.lastUsedAt,
@@ -134,7 +165,7 @@ export class ApiTokenManager {
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
stored.tokenHash = this.hashToken(rawToken);
await this.persistToken(stored);
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
return { id, rawToken };
@@ -165,6 +196,7 @@ export class ApiTokenManager {
name: doc.name,
tokenHash: doc.tokenHash,
scopes: doc.scopes,
policy: doc.policy,
createdAt: doc.createdAt,
expiresAt: doc.expiresAt,
lastUsedAt: doc.lastUsedAt,
@@ -175,12 +207,48 @@ export class ApiTokenManager {
}
}
private async ensureEnvAdminToken(): Promise<void> {
const rawToken = process.env.DCROUTER_ADMIN_API_TOKEN?.trim();
if (!rawToken) return;
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) {
throw new Error(`DCROUTER_ADMIN_API_TOKEN must start with ${TOKEN_PREFIX_STR}`);
}
if (rawToken.length < TOKEN_PREFIX_STR.length + 32) {
throw new Error('DCROUTER_ADMIN_API_TOKEN is too short');
}
const now = Date.now();
const existing = this.tokens.get(ENV_ADMIN_TOKEN_ID);
const stored: IStoredApiToken = {
id: ENV_ADMIN_TOKEN_ID,
name: process.env.DCROUTER_ADMIN_API_TOKEN_NAME?.trim() || 'Environment Admin Token',
tokenHash: this.hashToken(rawToken),
scopes: ['*'],
policy: { role: 'admin' },
createdAt: existing?.createdAt || now,
expiresAt: null,
lastUsedAt: existing?.lastUsedAt || null,
createdBy: existing?.createdBy || ENV_ADMIN_TOKEN_CREATED_BY,
enabled: true,
};
this.tokens.set(stored.id, stored);
await this.persistToken(stored);
logger.log('info', `Environment admin API token ensured (id: ${stored.id})`);
}
private hashToken(rawToken: string): string {
return plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
}
private async persistToken(stored: IStoredApiToken): Promise<void> {
const existing = await ApiTokenDoc.findById(stored.id);
if (existing) {
existing.name = stored.name;
existing.tokenHash = stored.tokenHash;
existing.scopes = stored.scopes;
existing.policy = stored.policy;
existing.createdAt = stored.createdAt;
existing.expiresAt = stored.expiresAt;
existing.lastUsedAt = stored.lastUsedAt;
@@ -193,6 +261,7 @@ export class ApiTokenManager {
doc.name = stored.name;
doc.tokenHash = stored.tokenHash;
doc.scopes = stored.scopes;
doc.policy = stored.policy;
doc.createdAt = stored.createdAt;
doc.expiresAt = stored.expiresAt;
doc.lastUsedAt = stored.lastUsedAt;
+117
View File
@@ -0,0 +1,117 @@
import * as plugins from '../plugins.js';
import { GatewayClientDoc } from '../db/index.js';
import type { IGatewayClient } from '../../ts_interfaces/data/workhoster.js';
const defaultCapabilities: IGatewayClient['capabilities'] = {
readDomains: true,
readDnsRecords: true,
syncRoutes: true,
syncDnsRecords: false,
requestCertificates: false,
};
export class GatewayClientManager {
public async initialize(): Promise<void> {}
public async listClients(): Promise<IGatewayClient[]> {
const docs = await GatewayClientDoc.findAll();
return docs.map((doc) => this.toPublicClient(doc));
}
public async getClient(id: string): Promise<IGatewayClient | null> {
const doc = await GatewayClientDoc.findById(id);
return doc ? this.toPublicClient(doc) : null;
}
public async createClient(options: {
id?: string;
type: IGatewayClient['type'];
name: string;
description?: string;
hostnamePatterns?: string[];
allowedRouteTargets?: IGatewayClient['allowedRouteTargets'];
capabilities?: IGatewayClient['capabilities'];
createdBy: string;
}): Promise<IGatewayClient> {
const id = this.normalizeId(options.id || `${options.type}-${plugins.uuid.v4()}`);
if (!id) {
throw new Error('gateway client id is required');
}
if (await GatewayClientDoc.findById(id)) {
throw new Error('gateway client already exists');
}
const now = Date.now();
const doc = new GatewayClientDoc();
doc.id = id;
doc.type = options.type;
doc.name = options.name.trim();
doc.description = options.description?.trim() || undefined;
doc.hostnamePatterns = this.normalizeStringList(options.hostnamePatterns || []);
doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(options.allowedRouteTargets || []);
doc.capabilities = { ...defaultCapabilities, ...(options.capabilities || {}) };
doc.enabled = true;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = options.createdBy;
await doc.save();
return this.toPublicClient(doc);
}
public async updateClient(
id: string,
patch: Partial<Pick<IGatewayClient, 'name' | 'description' | 'hostnamePatterns' | 'allowedRouteTargets' | 'capabilities' | 'enabled'>>,
): Promise<IGatewayClient | null> {
const doc = await GatewayClientDoc.findById(id);
if (!doc) return null;
if (patch.name !== undefined) doc.name = patch.name.trim();
if (patch.description !== undefined) doc.description = patch.description.trim() || undefined;
if (patch.hostnamePatterns !== undefined) doc.hostnamePatterns = this.normalizeStringList(patch.hostnamePatterns);
if (patch.allowedRouteTargets !== undefined) doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(patch.allowedRouteTargets);
if (patch.capabilities !== undefined) doc.capabilities = { ...defaultCapabilities, ...patch.capabilities };
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
doc.updatedAt = Date.now();
await doc.save();
return this.toPublicClient(doc);
}
public async deleteClient(id: string): Promise<boolean> {
const doc = await GatewayClientDoc.findById(id);
if (!doc) return false;
await doc.delete();
return true;
}
private normalizeId(id: string): string {
return id.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
private normalizeStringList(values: string[]): string[] {
return values.map((value) => value.trim().toLowerCase()).filter(Boolean);
}
private normalizeAllowedRouteTargets(targets: IGatewayClient['allowedRouteTargets']): IGatewayClient['allowedRouteTargets'] {
return targets
.map((target) => ({
host: target.host.trim().toLowerCase(),
ports: target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535),
}))
.filter((target) => target.host && target.ports.length > 0);
}
private toPublicClient(doc: GatewayClientDoc): IGatewayClient {
return {
id: doc.id,
type: doc.type,
name: doc.name,
description: doc.description,
hostnamePatterns: doc.hostnamePatterns || [],
allowedRouteTargets: doc.allowedRouteTargets || [],
capabilities: doc.capabilities || {},
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
}
+290 -69
View File
@@ -11,8 +11,12 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] };
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
export interface IRouteMutationResult {
success: boolean;
message?: string;
}
/**
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
@@ -52,9 +56,11 @@ export class RouteConfigManager {
constructor(
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
) {}
/** Expose routes map for reference resolution lookups. */
@@ -62,8 +68,19 @@ export class RouteConfigManager {
return this.routes;
}
public getRoute(id: string): IRoute | undefined {
return this.routes.get(id);
}
public setVpnClientAccessResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
): void {
this.getVpnClientAccessForRoute = resolver;
}
/**
* Load persisted routes, seed config/email/dns routes, compute warnings, apply to SmartProxy.
* Load persisted routes, seed serializable config/email/dns routes,
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
*/
public async initialize(
configRoutes: IDcRouterRouteConfig[] = [],
@@ -92,6 +109,7 @@ export class RouteConfigManager {
id: route.id,
enabled: route.enabled,
origin: route.origin,
systemKey: route.systemKey,
createdAt: route.createdAt,
updatedAt: route.updatedAt,
metadata: route.metadata,
@@ -120,11 +138,11 @@ export class RouteConfigManager {
}
// Resolve references if metadata has refs and resolver is available
let resolvedMetadata = metadata;
if (metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(route, metadata);
let resolvedMetadata = this.normalizeRouteMetadata(metadata);
if (resolvedMetadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
route = resolved.route;
resolvedMetadata = resolved.metadata;
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
}
const stored: IRoute = {
@@ -151,9 +169,21 @@ export class RouteConfigManager {
enabled?: boolean;
metadata?: Partial<IRouteMetadata>;
},
): Promise<boolean> {
): Promise<IRouteMutationResult> {
const stored = this.routes.get(id);
if (!stored) return false;
if (!stored) {
return { success: false, message: 'Route not found' };
}
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
&& patch.metadata === undefined;
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
return {
success: false,
message: 'System routes are managed by the system and can only be toggled',
};
}
if (patch.route) {
const mergedAction = patch.route.action
@@ -167,42 +197,73 @@ export class RouteConfigManager {
}
}
}
stored.route = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
// Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null)
for (const [key, val] of Object.entries(patch.route)) {
if (val === null && key !== 'action' && key !== 'match') {
delete (mergedRoute as any)[key];
}
}
stored.route = mergedRoute;
}
if (patch.enabled !== undefined) {
stored.enabled = patch.enabled;
}
if (patch.metadata !== undefined) {
stored.metadata = { ...stored.metadata, ...patch.metadata };
stored.metadata = this.normalizeRouteMetadata({
...stored.metadata,
...patch.metadata,
});
}
// Re-resolve if metadata refs exist and resolver is available
if (stored.metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route;
stored.metadata = resolved.metadata;
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
}
stored.updatedAt = Date.now();
await this.persistRoute(stored);
await this.applyRoutes();
return true;
return { success: true };
}
public async deleteRoute(id: string): Promise<boolean> {
if (!this.routes.has(id)) return false;
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
const stored = this.routes.get(id);
if (!stored) {
return { success: false, message: 'Route not found' };
}
if (stored.origin !== 'api') {
return {
success: false,
message: 'System routes are managed by the system and cannot be deleted',
};
}
this.routes.delete(id);
const doc = await RouteDoc.findById(id);
if (doc) await doc.delete();
await this.applyRoutes();
return true;
return { success: true };
}
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
return this.updateRoute(id, { enabled });
}
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
// =========================================================================
@@ -215,29 +276,28 @@ export class RouteConfigManager {
seedRoutes: IDcRouterRouteConfig[],
origin: 'config' | 'email' | 'dns',
): Promise<void> {
if (seedRoutes.length === 0) return;
const seedSystemKeys = new Set<string>();
const seedNames = new Set<string>();
let seeded = 0;
let updated = 0;
for (const route of seedRoutes) {
const name = route.name || '';
seedNames.add(name);
// Check if a route with this name+origin already exists in memory
let existingId: string | undefined;
for (const [id, r] of this.routes) {
if (r.origin === origin && r.route.name === name) {
existingId = id;
break;
}
if (name) {
seedNames.add(name);
}
const systemKey = this.buildSystemRouteKey(origin, route);
if (systemKey) {
seedSystemKeys.add(systemKey);
}
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
if (existingId) {
// Update route config but preserve enabled state
const existing = this.routes.get(existingId)!;
existing.route = route;
existing.systemKey = systemKey;
existing.updatedAt = Date.now();
await this.persistRoute(existing);
updated++;
@@ -253,6 +313,7 @@ export class RouteConfigManager {
updatedAt: now,
createdBy: 'system',
origin,
systemKey,
};
this.routes.set(id, newRoute);
await this.persistRoute(newRoute);
@@ -263,7 +324,12 @@ export class RouteConfigManager {
// Delete stale routes: same origin but name not in current seed set
const staleIds: string[] = [];
for (const [id, r] of this.routes) {
if (r.origin === origin && !seedNames.has(r.route.name || '')) {
if (r.origin !== origin) continue;
const routeName = r.route.name || '';
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
if (!matchesSeedSystemKey && !matchesSeedName) {
staleIds.push(id);
}
}
@@ -282,21 +348,56 @@ export class RouteConfigManager {
// Private: persistence
// =========================================================================
private buildSystemRouteKey(
origin: 'config' | 'email' | 'dns',
route: IDcRouterRouteConfig,
): string | undefined {
const name = route.name?.trim();
if (!name) return undefined;
return `${origin}:${name}`;
}
private findExistingSeedRouteId(
origin: 'config' | 'email' | 'dns',
route: IDcRouterRouteConfig,
systemKey?: string,
): string | undefined {
const routeName = route.name || '';
for (const [id, storedRoute] of this.routes) {
if (storedRoute.origin !== origin) continue;
if (systemKey && storedRoute.systemKey === systemKey) {
return id;
}
if (storedRoute.route.name === routeName) {
return id;
}
}
return undefined;
}
private async loadRoutes(): Promise<void> {
const docs = await RouteDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.routes.set(doc.id, {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
origin: doc.origin || 'api',
metadata: doc.metadata,
});
}
if (!doc.id) continue;
const storedRoute: IRoute = {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
origin: doc.origin || 'api',
systemKey: doc.systemKey,
metadata: this.normalizeRouteMetadata(doc.metadata),
};
this.routes.set(doc.id, storedRoute);
}
if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
@@ -311,6 +412,7 @@ export class RouteConfigManager {
existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin;
existingDoc.systemKey = stored.systemKey;
existingDoc.metadata = stored.metadata;
await existingDoc.save();
} else {
@@ -322,11 +424,80 @@ export class RouteConfigManager {
doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy;
doc.origin = stored.origin;
doc.systemKey = stored.systemKey;
doc.metadata = stored.metadata;
await doc.save();
}
}
private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
if (!metadata) {
return undefined;
}
const normalizeString = (value?: string): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const normalized: IRouteMetadata = {
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
networkTargetRef: normalizeString(metadata.networkTargetRef),
sourceProfileName: normalizeString(metadata.sourceProfileName),
networkTargetName: normalizeString(metadata.networkTargetName),
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
? metadata.lastResolvedAt
: undefined,
ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
? metadata.ownerType
: undefined,
gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
? metadata.gatewayClientType
: metadata.workHosterType,
gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
? metadata.workHosterType
: metadata.gatewayClientType,
workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
externalKey: normalizeString(metadata.externalKey),
};
if (!normalized.sourceProfileRef) {
normalized.sourceProfileName = undefined;
}
if (!normalized.networkTargetRef) {
normalized.networkTargetName = undefined;
}
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
normalized.gatewayClientType = undefined;
normalized.gatewayClientId = undefined;
normalized.gatewayClientAppId = undefined;
normalized.workHosterType = undefined;
normalized.workHosterId = undefined;
normalized.workAppId = undefined;
normalized.externalKey = undefined;
} else {
normalized.ownerType = 'gatewayClient';
normalized.workHosterType = normalized.gatewayClientType;
normalized.workHosterId = normalized.gatewayClientId;
normalized.workAppId = normalized.gatewayClientAppId;
}
if (Object.values(normalized).every((value) => value === undefined)) {
return undefined;
}
return normalized;
}
// =========================================================================
// Private: warnings
// =========================================================================
@@ -369,7 +540,7 @@ export class RouteConfigManager {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route;
stored.metadata = resolved.metadata;
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
stored.updatedAt = Date.now();
await this.persistRoute(stored);
}
@@ -389,44 +560,94 @@ export class RouteConfigManager {
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const http3Config = this.getHttp3Config?.();
const vpnCallback = this.getVpnClientIpsForRoute;
// Helper: inject VPN security into a vpnOnly route
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnEntries = vpnCallback(dcRoute, routeId);
const existingEntries = route.security?.ipAllowList || [];
return {
...route,
security: {
...route.security,
ipAllowList: [...existingEntries, ...vpnEntries],
},
};
};
// Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
let r = route.route;
if (http3Config?.enabled !== false) {
r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config });
}
enabledRoutes.push(injectVpn(r, route.id));
enabledRoutes.push(this.prepareStoredRouteForApply(route));
}
}
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
for (const route of runtimeRoutes) {
enabledRoutes.push(this.prepareRouteForApply(route));
}
await smartProxy.updateRoutes(enabledRoutes);
// Notify listeners (e.g. RemoteIngressManager) of the route set
if (this.onRoutesApplied) {
this.onRoutesApplied(enabledRoutes);
await this.onRoutesApplied(enabledRoutes);
}
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
});
}
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
}
private prepareRouteForApply(
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
let preparedRoute = route;
const http3Config = this.getHttp3Config?.();
if (http3Config?.enabled !== false) {
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
}
return this.injectVpnSecurity(preparedRoute, routeId);
}
private injectVpnSecurity(
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const dcRoute = route as IDcRouterRouteConfig;
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
return route;
}
const existingVpnSecurity = route.security?.vpn || {};
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
existingVpnSecurity.allowedClients || [],
vpnEntries,
);
return {
...route,
security: {
...route.security,
vpn: {
...existingVpnSecurity,
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
allowedClients: mergedAllowedClients,
},
},
};
}
private mergeVpnClientAllowEntries(
existingEntries: TVpnClientAllowEntry[],
vpnEntries: TVpnClientAllowEntry[],
): TVpnClientAllowEntry[] {
const merged: TVpnClientAllowEntry[] = [];
const seen = new Set<string>();
for (const entry of [...existingEntries, ...vpnEntries]) {
const key = typeof entry === 'string'
? `client:${entry}`
: `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
}
return merged;
}
}
+183 -23
View File
@@ -5,6 +5,8 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
/**
* Manages TargetProfiles (target-side: what can be accessed).
* TargetProfiles define what resources a VPN client can reach:
@@ -13,6 +15,10 @@ import type { IRoute } from '../../ts_interfaces/data/route-management.js';
export class TargetProfileManager {
private profiles = new Map<string, ITargetProfile>();
constructor(
private getAllRoutes?: () => Map<string, IRoute>,
) {}
// =========================================================================
// Lifecycle
// =========================================================================
@@ -31,6 +37,7 @@ export class TargetProfileManager {
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
createdBy: string;
}): Promise<string> {
// Enforce unique profile names
@@ -43,13 +50,15 @@ export class TargetProfileManager {
const id = plugins.uuid.v4();
const now = Date.now();
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
const profile: ITargetProfile = {
id,
name: data.name,
description: data.description,
domains: data.domains,
targets: data.targets,
routeRefs: data.routeRefs,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
@@ -70,11 +79,22 @@ export class TargetProfileManager {
throw new Error(`Target profile '${id}' not found`);
}
if (patch.name !== undefined && patch.name !== profile.name) {
for (const existing of this.profiles.values()) {
if (existing.id !== id && existing.name === patch.name) {
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
}
}
}
if (patch.name !== undefined) profile.name = patch.name;
if (patch.description !== undefined) profile.description = patch.description;
if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
if (patch.allowRoutesByClientSourceIp !== undefined) {
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
}
profile.updatedAt = Date.now();
await this.persistProfile(profile);
@@ -127,6 +147,29 @@ export class TargetProfileManager {
return this.profiles.get(id);
}
/**
* Normalize stored route references to route IDs when they can be resolved
* uniquely against the current route registry.
*/
public async normalizeAllRouteRefs(): Promise<void> {
const allRoutes = this.getAllRoutes?.();
if (!allRoutes?.size) return;
for (const profile of this.profiles.values()) {
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
profile.routeRefs,
allRoutes,
'bestEffort',
);
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
profile.routeRefs = normalizedRouteRefs;
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
}
}
public listProfiles(): ITargetProfile[] {
return [...this.profiles.values()];
}
@@ -163,27 +206,30 @@ export class TargetProfileManager {
}
// =========================================================================
// Core matching: route → client IPs
// Core matching: route → VPN client grants
// =========================================================================
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns IP allow entries for injection into ipAllowList.
* Find all enabled VPN clients whose assigned TargetProfile matches the route.
* Returns SmartProxy VPN client allow entries for authenticated metadata checks.
*
* Entries are domain-scoped when a profile matches via specific domains that are
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
* or when profile domains exactly equal the route's domains.
* or when profile domains exactly equal the route's domains. Profiles can also opt
* into source-policy routes; SmartProxy evaluates the real source IP per connection.
*/
public getMatchingClientIps(
public getMatchingVpnClients(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || [];
allRoutes: Map<string, IRoute> = new Map(),
): TVpnClientAllowEntry[] {
const entries: TVpnClientAllowEntry[] = [];
const routeDomains = this.getRouteDomains(route);
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue;
if (!client.enabled || !client.clientId) continue;
if (!client.targetProfileIds?.length) continue;
// Collect scoped domains from all matching profiles for this client
@@ -194,7 +240,13 @@ export class TargetProfileManager {
const profile = this.profiles.get(profileId);
if (!profile) continue;
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
const matchResult = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
if (matchResult === 'full') {
fullAccess = true;
break; // No need to check more profiles
@@ -202,12 +254,20 @@ export class TargetProfileManager {
if (matchResult !== 'none') {
for (const d of matchResult.domains) scopedDomains.add(d);
}
if (
profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(route)
) {
fullAccess = true;
break;
}
}
if (fullAccess) {
entries.push(client.assignedIp);
entries.push(client.clientId);
} else if (scopedDomains.size > 0) {
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
entries.push({ clientId: client.clientId, domains: [...scopedDomains] });
}
}
@@ -224,6 +284,7 @@ export class TargetProfileManager {
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
// Collect all access specifiers from assigned profiles
for (const profileId of targetProfileIds) {
@@ -247,12 +308,19 @@ export class TargetProfileManager {
// Route references: scan all routes
for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue;
if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
const routeDomains = (route.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
const dcRoute = route.route as IDcRouterRouteConfig;
const routeDomains = this.getRouteDomains(dcRoute);
const profileMatchesRoute = this.routeMatchesProfile(
dcRoute,
routeId,
profile,
routeNameIndex,
);
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(dcRoute);
if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
@@ -275,9 +343,16 @@ export class TargetProfileManager {
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
routeNameIndex: Map<string, string[]>,
): boolean {
const routeDomains: string[] = (route.match as any)?.domains || [];
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
const routeDomains = this.getRouteDomains(route);
const result = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
return result !== 'none';
}
@@ -294,11 +369,17 @@ export class TargetProfileManager {
routeId: string | undefined,
profile: ITargetProfile,
routeDomains: string[],
routeNameIndex: Map<string, string[]>,
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
// 1. Route reference match → full access
if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
const matchingRouteIds = routeNameIndex.get(route.name) || [];
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
return 'full';
}
}
}
// 2. Domain match
@@ -362,6 +443,82 @@ export class TargetProfileManager {
return false;
}
private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
const security = (route as any).security;
const blockEntries = Array.isArray(security?.ipBlockList)
? security.ipBlockList
: security?.ipBlockList
? [security.ipBlockList]
: [];
return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
}
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
const domains = (route.match as any)?.domains;
if (!domains) return [];
return Array.isArray(domains) ? domains : [domains];
}
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
}
private normalizeRouteRefsAgainstRoutes(
routeRefs: string[] | undefined,
allRoutes: Map<string, IRoute>,
mode: 'strict' | 'bestEffort',
): string[] | undefined {
if (!routeRefs?.length) return undefined;
if (!allRoutes.size) return [...new Set(routeRefs)];
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
const normalizedRefs = new Set<string>();
for (const routeRef of routeRefs) {
if (allRoutes.has(routeRef)) {
normalizedRefs.add(routeRef);
continue;
}
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
if (matchingRouteIds.length === 1) {
normalizedRefs.add(matchingRouteIds[0]);
continue;
}
if (mode === 'bestEffort') {
normalizedRefs.add(routeRef);
continue;
}
if (matchingRouteIds.length > 1) {
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
}
throw new Error(`Route reference '${routeRef}' not found`);
}
return [...normalizedRefs];
}
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
const routeNameIndex = new Map<string, string[]>();
for (const [routeId, route] of allRoutes) {
const routeName = route.route.name;
if (!routeName) continue;
const matchingRouteIds = routeNameIndex.get(routeName) || [];
matchingRouteIds.push(routeId);
routeNameIndex.set(routeName, matchingRouteIds);
}
return routeNameIndex;
}
private sameStringArray(left?: string[], right?: string[]): boolean {
if (!left?.length && !right?.length) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
// =========================================================================
// Private: persistence
// =========================================================================
@@ -377,6 +534,7 @@ export class TargetProfileManager {
domains: doc.domains,
targets: doc.targets,
routeRefs: doc.routeRefs,
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
@@ -396,6 +554,7 @@ export class TargetProfileManager {
existingDoc.domains = profile.domains;
existingDoc.targets = profile.targets;
existingDoc.routeRefs = profile.routeRefs;
existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
@@ -406,6 +565,7 @@ export class TargetProfileManager {
doc.domains = profile.domains;
doc.targets = profile.targets;
doc.routeRefs = profile.routeRefs;
doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
+2 -1
View File
@@ -2,6 +2,7 @@
export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';
export { GatewayClientManager } from './classes.gateway-client-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js';
export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
+4 -1
View File
@@ -1,6 +1,6 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
import type { IApiTokenPolicy, TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@@ -19,6 +19,9 @@ export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, A
@plugins.smartdata.svDb()
public scopes!: TApiTokenScope[];
@plugins.smartdata.svDb()
public policy?: IApiTokenPolicy;
@plugins.smartdata.svDb()
public createdAt!: number;
@@ -0,0 +1,54 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IApiTokenPolicy, TGatewayClientType } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc<GatewayClientDoc, GatewayClientDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public type!: TGatewayClientType;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public hostnamePatterns: string[] = [];
@plugins.smartdata.svDb()
public allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']> = [];
@plugins.smartdata.svDb()
public capabilities: NonNullable<IApiTokenPolicy['capabilities']> = {};
@plugins.smartdata.svDb()
public enabled: boolean = true;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<GatewayClientDoc | null> {
return await GatewayClientDoc.getInstance({ id });
}
public static async findAll(): Promise<GatewayClientDoc[]> {
return await GatewayClientDoc.getInstances({});
}
}
@@ -0,0 +1,78 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IIpIntelligenceRecord } from '../../../ts_interfaces/data/security-policy.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntelligenceDoc, IpIntelligenceDoc> implements IIpIntelligenceRecord {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public ipAddress!: string;
@plugins.smartdata.svDb()
public asn: number | null = null;
@plugins.smartdata.svDb()
public asnOrg: string | null = null;
@plugins.smartdata.svDb()
public registrantOrg: string | null = null;
@plugins.smartdata.svDb()
public registrantCountry: string | null = null;
@plugins.smartdata.svDb()
public networkRange: string | null = null;
@plugins.smartdata.svDb()
public networkCidrs: string[] | null = null;
@plugins.smartdata.svDb()
public abuseContact: string | null = null;
@plugins.smartdata.svDb()
public country: string | null = null;
@plugins.smartdata.svDb()
public countryCode: string | null = null;
@plugins.smartdata.svDb()
public city: string | null = null;
@plugins.smartdata.svDb()
public latitude: number | null = null;
@plugins.smartdata.svDb()
public longitude: number | null = null;
@plugins.smartdata.svDb()
public accuracyRadius: number | null = null;
@plugins.smartdata.svDb()
public timezone: string | null = null;
@plugins.smartdata.svDb()
public firstSeenAt: number = Date.now();
@plugins.smartdata.svDb()
public lastSeenAt: number = Date.now();
@plugins.smartdata.svDb()
public updatedAt: number = Date.now();
@plugins.smartdata.svDb()
public seenCount: number = 0;
constructor() {
super();
}
public static async findByIp(ipAddress: string): Promise<IpIntelligenceDoc | null> {
return await IpIntelligenceDoc.getInstance({ ipAddress });
}
public static async findAll(): Promise<IpIntelligenceDoc[]> {
return await IpIntelligenceDoc.getInstances({});
}
}
+7
View File
@@ -29,6 +29,9 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
@plugins.smartdata.svDb()
public origin!: 'config' | 'email' | 'dns' | 'api';
@plugins.smartdata.svDb()
public systemKey?: string;
@plugins.smartdata.svDb()
public metadata?: IRouteMetadata;
@@ -51,4 +54,8 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
return await RouteDoc.getInstances({ origin });
}
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ systemKey });
}
}
@@ -0,0 +1,52 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { ISecurityBlockRule, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType } from '../../../ts_interfaces/data/security-policy.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class SecurityBlockRuleDoc extends plugins.smartdata.SmartDataDbDoc<SecurityBlockRuleDoc, SecurityBlockRuleDoc> implements ISecurityBlockRule {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public type!: TSecurityBlockRuleType;
@plugins.smartdata.svDb()
public value!: string;
@plugins.smartdata.svDb()
public matchMode?: TSecurityBlockRuleMatchMode;
@plugins.smartdata.svDb()
public enabled: boolean = true;
@plugins.smartdata.svDb()
public reason?: string;
@plugins.smartdata.svDb()
public createdAt: number = Date.now();
@plugins.smartdata.svDb()
public updatedAt: number = Date.now();
@plugins.smartdata.svDb()
public createdBy: string = 'system';
constructor() {
super();
}
public static async findById(id: string): Promise<SecurityBlockRuleDoc | null> {
return await SecurityBlockRuleDoc.getInstance({ id });
}
public static async findAll(): Promise<SecurityBlockRuleDoc[]> {
return await SecurityBlockRuleDoc.getInstances({});
}
public static async findEnabled(): Promise<SecurityBlockRuleDoc[]> {
return await SecurityBlockRuleDoc.getInstances({ enabled: true });
}
}
@@ -0,0 +1,33 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { ISecurityPolicyAuditEvent } from '../../../ts_interfaces/data/security-policy.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class SecurityPolicyAuditDoc extends plugins.smartdata.SmartDataDbDoc<SecurityPolicyAuditDoc, SecurityPolicyAuditDoc> implements ISecurityPolicyAuditEvent {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public action!: string;
@plugins.smartdata.svDb()
public actor!: string;
@plugins.smartdata.svDb()
public details!: Record<string, unknown>;
@plugins.smartdata.svDb()
public createdAt: number = Date.now();
constructor() {
super();
}
public static async findRecent(limit = 100): Promise<SecurityPolicyAuditDoc[]> {
const docs = await SecurityPolicyAuditDoc.getInstances({});
return docs.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
}
}
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
@plugins.smartdata.svDb()
public routeRefs?: string[];
@plugins.smartdata.svDb()
public allowRoutesByClientSourceIp?: boolean;
@plugins.smartdata.svDb()
public createdAt!: number;
+4
View File
@@ -1,10 +1,14 @@
// Cached/TTL document classes
export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js';
export * from './classes.ip-intelligence.doc.js';
export * from './classes.security-block-rule.doc.js';
export * from './classes.security-policy-audit.doc.js';
// Config document classes
export * from './classes.route.doc.js';
export * from './classes.api-token.doc.js';
export * from './classes.gateway-client.doc.js';
export * from './classes.source-profile.doc.js';
export * from './classes.target-profile.doc.js';
export * from './classes.network-target.doc.js';
+27 -10
View File
@@ -97,8 +97,8 @@ export class DnsManager {
if (hasLegacyConfig) {
logger.log(
'warn',
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
'Manage DNS via the Domains UI instead.',
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
);
}
return;
@@ -209,9 +209,9 @@ export class DnsManager {
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
if (!this.dnsServer) return;
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
if (question.name === rec.name && question.type === rec.type) {
if (question.name.toLowerCase() === rec.name.toLowerCase() && question.type.toUpperCase() === rec.type) {
return {
name: rec.name,
name: question.name,
type: rec.type,
class: 'IN',
ttl: rec.ttl,
@@ -313,17 +313,23 @@ export class DnsManager {
}
/**
* Delete all DNS records matching a name and type under a domain.
* Used for ACME challenge cleanup (may have multiple TXT records at the same name).
* Delete DNS records matching a name and type under a domain.
* When value is provided, only that exact record is removed so parallel ACME
* challenges for the same host can coexist.
*/
public async deleteRecordsByNameAndType(
domainId: string,
name: string,
type: TDnsRecordType,
value?: string,
): Promise<void> {
const records = await DnsRecordDoc.findByDomainId(domainId);
for (const rec of records) {
if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
if (
rec.name.toLowerCase() === name.toLowerCase()
&& rec.type === type
&& (value === undefined || rec.value === value)
) {
await this.deleteRecord(rec.id);
}
}
@@ -358,9 +364,15 @@ export class DnsManager {
'Add the domain in Domains before issuing certificates.',
);
}
// Clean leftover challenge records first to avoid duplicates.
// Clean only the same challenge value. Exact + wildcard SAN orders can
// legitimately need multiple TXT records at the same name.
try {
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
await self.deleteRecordsByNameAndType(
domainDoc.id,
dnsChallenge.hostName,
'TXT',
dnsChallenge.challenge,
);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
@@ -381,7 +393,12 @@ export class DnsManager {
return;
}
try {
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
await self.deleteRecordsByNameAndType(
domainDoc.id,
dnsChallenge.hostName,
'TXT',
dnsChallenge.challenge,
);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
+167 -50
View File
@@ -1,10 +1,12 @@
import * as plugins from '../plugins.js';
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
import { logger } from '../logger.js';
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
import type { DnsManager } from '../dns/manager.dns.js';
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
import { buildEmailDnsRecords } from './email-dns-records.js';
/**
* EmailDomainManager — orchestrates email domain setup.
@@ -15,9 +17,12 @@ import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_i
*/
export class EmailDomainManager {
private dcRouter: any; // DcRouter — avoids circular import
private readonly baseEmailDomains: IEmailDomainConfig[];
constructor(dcRouterRef: any) {
this.dcRouter = dcRouterRef;
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
private get dnsManager(): DnsManager | undefined {
@@ -32,6 +37,12 @@ export class EmailDomainManager {
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
}
public async start(): Promise<void> {
await this.syncManagedDomainsToRuntime();
}
public async stop(): Promise<void> {}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
@@ -46,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;
@@ -64,6 +100,9 @@ export class EmailDomainManager {
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
// Check for duplicates
if (this.isDomainAlreadyConfigured(domainName)) {
throw new Error(`Email domain already configured for ${domainName}`);
}
const existing = await EmailDomainDoc.findByDomain(domainName);
if (existing) {
throw new Error(`Email domain already exists for ${domainName}`);
@@ -77,8 +116,8 @@ export class EmailDomainManager {
let publicKey: string | undefined;
if (this.dkimCreator) {
try {
await this.dkimCreator.handleDKIMKeysForDomain(domainName);
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
// Extract public key from the DNS record value
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
publicKey = match ? match[1] : undefined;
@@ -110,6 +149,7 @@ export class EmailDomainManager {
doc.createdAt = now;
doc.updatedAt = now;
await doc.save();
await this.syncManagedDomainsToRuntime();
logger.log('info', `Email domain created: ${domainName}`);
return this.docToInterface(doc);
@@ -131,12 +171,14 @@ export class EmailDomainManager {
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
doc.updatedAt = new Date().toISOString();
await doc.save();
await this.syncManagedDomainsToRuntime();
}
public async deleteEmailDomain(id: string): Promise<void> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
await doc.delete();
await this.syncManagedDomainsToRuntime();
logger.log('info', `Email domain deleted: ${doc.domain}`);
}
@@ -153,37 +195,25 @@ export class EmailDomainManager {
const domain = doc.domain;
const selector = doc.dkim.selector;
const publicKey = doc.dkim.publicKey || '';
const hostname = this.emailHostname;
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
const records: IEmailDnsRecord[] = [
{
type: 'MX',
name: domain,
value: `10 ${hostname}`,
status: doc.dnsStatus.mx,
},
{
type: 'TXT',
name: domain,
value: 'v=spf1 a mx ~all',
status: doc.dnsStatus.spf,
},
{
type: 'TXT',
name: `${selector}._domainkey.${domain}`,
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
status: doc.dnsStatus.dkim,
},
{
type: 'TXT',
name: `_dmarc.${domain}`,
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
status: doc.dnsStatus.dmarc,
},
];
if (this.dkimCreator) {
try {
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
dkimValue = dnsRecord.value;
} catch (err: unknown) {
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
}
}
return records;
return buildEmailDnsRecords({
domain,
hostname,
selector,
dkimValue,
statuses: doc.dnsStatus,
});
}
// ---------------------------------------------------------------------------
@@ -207,17 +237,7 @@ export class EmailDomainManager {
for (const required of requiredRecords) {
// Check if a matching record already exists
const exists = existingRecords.some((r) => {
if (required.type === 'MX') {
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
}
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
return false;
});
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
if (!exists) {
try {
@@ -259,16 +279,23 @@ export class EmailDomainManager {
const resolver = new plugins.dns.promises.Resolver();
// MX check
doc.dnsStatus.mx = await this.checkMx(resolver, domain);
const requiredRecords = await this.getRequiredDnsRecords(id);
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
// SPF check
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
// DKIM check
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1');
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
// DMARC check
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1');
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
doc.updatedAt = new Date().toISOString();
@@ -277,10 +304,28 @@ export class EmailDomainManager {
return this.getRequiredDnsRecords(id);
}
private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
return false;
}
return record.value.trim() === required.value.trim();
}
private async checkMx(
resolver: plugins.dns.promises.Resolver,
domain: string,
expectedValue?: string,
): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveMx(domain);
return records && records.length > 0 ? 'valid' : 'missing';
if (!records || records.length === 0) {
return 'missing';
}
if (!expectedValue) {
return 'valid';
}
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
return found ? 'valid' : 'invalid';
} catch {
return 'missing';
}
@@ -289,13 +334,19 @@ export class EmailDomainManager {
private async checkTxtRecord(
resolver: plugins.dns.promises.Resolver,
name: string,
prefix: string,
expectedValue?: string,
): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveTxt(name);
const flat = records.map((r) => r.join(''));
const found = flat.some((r) => r.startsWith(prefix));
return found ? 'valid' : 'missing';
if (flat.length === 0) {
return 'missing';
}
if (!expectedValue) {
return 'valid';
}
const found = flat.some((record) => record.trim() === expectedValue.trim());
return found ? 'valid' : 'invalid';
} catch {
return 'missing';
}
@@ -318,4 +369,70 @@ export class EmailDomainManager {
updatedAt: doc.updatedAt,
};
}
private isDomainAlreadyConfigured(domainName: string): boolean {
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
.map((domainConfig) => domainConfig.domain.toLowerCase());
return configuredDomains.includes(domainName.toLowerCase());
}
private async 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[] = [];
for (const doc of docs) {
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
if (!linkedDomain) {
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
continue;
}
managedConfigs.push({
domain: doc.domain,
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
dkim: {
selector: doc.dkim.selector,
keySize: doc.dkim.keySize,
rotateKeys: doc.dkim.rotateKeys,
rotationInterval: doc.dkim.rotationIntervalDays,
},
rateLimits: doc.rateLimits,
});
}
return managedConfigs;
}
public async syncManagedDomainsToRuntime(): Promise<void> {
if (!this.dcRouter.options?.emailConfig) {
return;
}
const mergedDomains = new Map<string, IEmailDomainConfig>();
for (const domainConfig of this.baseEmailDomains) {
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
for (const managedConfig of await this.buildManagedDomainConfigs()) {
const key = managedConfig.domain.toLowerCase();
if (mergedDomains.has(key)) {
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
continue;
}
mergedDomains.set(key, managedConfig);
}
const domains = Array.from(mergedDomains.values());
this.dcRouter.options.emailConfig.domains = domains;
if (this.dcRouter.emailServer) {
this.dcRouter.emailServer.updateOptions({ domains });
}
}
}
@@ -0,0 +1,108 @@
import * as plugins from '../plugins.js';
import type { IStorageManagerLike } from '@push.rocks/smartmta';
export class SmartMtaStorageManager implements IStorageManagerLike {
private readonly resolvedRootDir: string;
constructor(private rootDir: string) {
this.resolvedRootDir = plugins.path.resolve(rootDir);
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
}
private normalizeKey(key: string): string {
return key.replace(/^\/+/, '').replace(/\\/g, '/');
}
private resolvePathForKey(key: string): string {
const normalizedKey = this.normalizeKey(key);
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
if (
resolvedPath !== this.resolvedRootDir
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
) {
throw new Error(`Storage key escapes root directory: ${key}`);
}
return resolvedPath;
}
private toStorageKey(filePath: string): string {
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
return `/${relativePath}`;
}
public async get(key: string): Promise<string | null> {
const filePath = this.resolvePathForKey(key);
try {
return await plugins.fs.promises.readFile(filePath, 'utf8');
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
}
public async set(key: string, value: string): Promise<void> {
const filePath = this.resolvePathForKey(key);
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
}
public async list(prefix: string): Promise<string[]> {
const prefixPath = this.resolvePathForKey(prefix);
try {
const stat = await plugins.fs.promises.stat(prefixPath);
if (stat.isFile()) {
return [this.toStorageKey(prefixPath)];
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const results: string[] = [];
const walk = async (currentPath: string): Promise<void> => {
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = plugins.path.join(currentPath, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
} else if (entry.isFile()) {
results.push(this.toStorageKey(entryPath));
}
}
};
await walk(prefixPath);
return results.sort();
}
public async delete(key: string): Promise<void> {
const targetPath = this.resolvePathForKey(key);
try {
const stat = await plugins.fs.promises.stat(targetPath);
if (stat.isDirectory()) {
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
} else {
await plugins.fs.promises.unlink(targetPath);
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
throw error;
}
let currentDir = plugins.path.dirname(targetPath);
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
const entries = await plugins.fs.promises.readdir(currentDir);
if (entries.length > 0) {
break;
}
await plugins.fs.promises.rmdir(currentDir);
currentDir = plugins.path.dirname(currentDir);
}
}
}
+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;
}
}
+53
View File
@@ -0,0 +1,53 @@
import type {
IEmailDnsRecord,
TDnsRecordStatus,
} from '../../ts_interfaces/data/email-domain.js';
type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
export interface IBuildEmailDnsRecordsOptions {
domain: string;
hostname: string;
selector?: string;
dkimValue?: string;
mxPriority?: number;
dmarcPolicy?: string;
dmarcRua?: string;
statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
}
export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
const selector = options.selector || 'default';
const records: IEmailDnsRecord[] = [
{
type: 'MX',
name: options.domain,
value: `${options.mxPriority ?? 10} ${options.hostname}`,
status: statusFor('mx'),
},
{
type: 'TXT',
name: options.domain,
value: 'v=spf1 a mx ~all',
status: statusFor('spf'),
},
{
type: 'TXT',
name: `_dmarc.${options.domain}`,
value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
status: statusFor('dmarc'),
},
];
if (options.dkimValue) {
records.splice(2, 0, {
type: 'TXT',
name: `${selector}._domainkey.${options.domain}`,
value: options.dkimValue,
status: statusFor('dkim'),
});
}
return records;
}
+3
View File
@@ -1 +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';
+23
View File
@@ -1,3 +1,4 @@
import { commitinfo } from './00_commitinfo_data.js';
export * from './00_commitinfo_data.js';
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
@@ -18,6 +19,28 @@ export * from './remoteingress/index.js';
export type { IHttp3Config } from './http3/index.js';
export const runCli = async () => {
const args = process.argv.slice(2);
if (args.includes('--version') || args.includes('version')) {
console.log(commitinfo.version);
return;
}
if (args.includes('--help') || args.includes('-h') || args.includes('help')) {
console.log(`dcrouter ${commitinfo.version}
Usage:
dcrouter
dcrouter --version
dcrouter --help
Environment:
DCROUTER_MODE=OCI_CONTAINER Start with OCI container configuration
DATA_DIR=<path> Override the writable dcrouter data directory
`);
return;
}
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {
+187 -104
View File
@@ -3,6 +3,7 @@ import { DcRouter } from '../classes.dcrouter.js';
import { MetricsCache } from './classes.metricscache.js';
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
import { logger } from '../logger.js';
import type { IAsnActivity } from '../../ts_interfaces/data/stats.js';
export class MetricsManager {
private metricsLogger: plugins.smartlog.Smartlog;
@@ -545,7 +546,7 @@ export class MetricsManager {
// Get network metrics from SmartProxy
public async getNetworkStats() {
// Use shorter cache TTL for network stats to ensure real-time updates
return this.metricsCache.get('networkStats', () => {
return this.metricsCache.get('networkStats', async () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
@@ -554,13 +555,16 @@ export class MetricsManager {
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
topASNs: [] as IAsnActivity[],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0,
requestsTotal: 0,
backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number }>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number; requestsPerSecond?: number; requestsLastMinute?: number }>,
frontendProtocols: null,
backendProtocols: null,
};
}
@@ -592,6 +596,7 @@ export class MetricsManager {
// Get HTTP request rates
const requestsPerSecond = proxyMetrics.requests.perSecond();
const requestsTotal = proxyMetrics.requests.total();
const domainRequestRates = proxyMetrics.requests.byDomain();
// Get frontend/backend protocol distribution
const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null;
@@ -619,47 +624,48 @@ export class MetricsManager {
const seenCacheKeys = new Set<string>();
for (const [key, bm] of backendMetrics) {
backends.push({
id: `backend:${key}`,
backend: key,
domain: null,
protocol: bm.protocol,
activeConnections: bm.activeConnections,
totalConnections: bm.totalConnections,
connectErrors: bm.connectErrors,
handshakeErrors: bm.handshakeErrors,
requestErrors: bm.requestErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Failures: bm.h2Failures,
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
cacheAgeSecs: null,
});
const cacheEntries = cacheByBackend.get(key);
if (!cacheEntries || cacheEntries.length === 0) {
// No protocol cache entry — emit one row with backend metrics only
backends.push({
backend: key,
domain: null,
protocol: bm.protocol,
activeConnections: bm.activeConnections,
totalConnections: bm.totalConnections,
connectErrors: bm.connectErrors,
handshakeErrors: bm.handshakeErrors,
requestErrors: bm.requestErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Failures: bm.h2Failures,
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
cacheAgeSecs: null,
});
} else {
// One row per domain, each enriched with the shared backend metrics
if (cacheEntries && cacheEntries.length > 0) {
// Protocol cache rows are domain-scoped metadata, not live backend connections.
for (const cache of cacheEntries) {
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
seenCacheKeys.add(compositeKey);
backends.push({
id: `cache:${compositeKey}`,
backend: key,
domain: cache.domain ?? null,
protocol: cache.protocol ?? bm.protocol,
activeConnections: bm.activeConnections,
totalConnections: bm.totalConnections,
connectErrors: bm.connectErrors,
handshakeErrors: bm.handshakeErrors,
requestErrors: bm.requestErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Failures: bm.h2Failures,
activeConnections: 0,
totalConnections: 0,
connectErrors: 0,
handshakeErrors: 0,
requestErrors: 0,
avgConnectTimeMs: 0,
poolHitRate: 0,
h2Failures: 0,
h2Suppressed: cache.h2Suppressed,
h3Suppressed: cache.h3Suppressed,
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
@@ -678,6 +684,7 @@ export class MetricsManager {
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
if (!seenCacheKeys.has(compositeKey)) {
backends.push({
id: `cache:${compositeKey}`,
backend: `${entry.host}:${entry.port}`,
domain: entry.domain,
protocol: entry.protocol,
@@ -720,122 +727,133 @@ export class MetricsManager {
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
// Build domain activity from per-route metrics
const observedIps = [...new Set([
...connectionsByIP.keys(),
...throughputByIP.keys(),
...topIPs.map((item) => item.ip),
...topIPsByBandwidth.map((item) => item.ip),
])];
this.dcRouter.securityPolicyManager?.queueObservedIps(observedIps);
const topASNs = await this.buildTopASNs(observedIps, allIPData);
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Map route name → ALL its domains (not just the first one)
// Aggregate per-IP domain request counts into per-domain totals
const domainRequestTotals = new Map<string, number>();
const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP();
for (const [, domainMap] of domainRequestsByIP) {
for (const [domain, count] of domainMap) {
domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count);
}
}
// Map canonical route key → domains from route config
const routeDomains = new Map<string, string[]>();
if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
if (!route.name || !route.match.domains) continue;
const routeKey = route.name || route.id;
if (!routeKey || !route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (domains.length > 0) {
routeDomains.set(route.name, domains);
routeDomains.set(routeKey, domains);
}
}
}
// Use protocol cache to discover actual active domains (resolves wildcards)
const activeDomains = new Set<string>();
const domainToBackend = new Map<string, string>(); // domain → host:port
// Resolve wildcards using domains seen in request metrics
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
for (const domain of domainRequestRates.keys()) {
allKnownDomains.add(domain);
}
for (const entry of protocolCache) {
if (entry.domain) {
activeDomains.add(entry.domain);
domainToBackend.set(entry.domain, `${entry.host}:${entry.port}`);
}
if (entry.domain) allKnownDomains.add(entry.domain);
}
// Build reverse map: domain → route name(s) that handle it
// For concrete domains: direct lookup from route config
// For wildcard patterns: match active domains from protocol cache
// Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>();
for (const [routeName, domains] of routeDomains) {
for (const [routeKey, domains] of routeDomains) {
for (const pattern of domains) {
if (pattern.includes('*')) {
// Wildcard pattern — match against active domains from protocol cache
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const activeDomain of activeDomains) {
if (regex.test(activeDomain)) {
const existing = domainToRoutes.get(activeDomain);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(activeDomain, [routeName]); }
for (const knownDomain of allKnownDomains) {
if (regex.test(knownDomain)) {
const existing = domainToRoutes.get(knownDomain);
if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(knownDomain, [routeKey]); }
}
}
} else {
// Concrete domain
const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(pattern, [routeName]); }
if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(pattern, [routeKey]); }
}
}
}
// Aggregate metrics per domain
// For each domain, sum metrics from all routes that serve it,
// divided by the number of domains each route serves
const hasLiveDomainRates = domainRequestRates.size > 0;
const getDomainWeight = (domain: string): number => {
const liveRate = domainRequestRates.get(domain);
return hasLiveDomainRates
? (liveRate?.lastMinute ?? 0)
: (domainRequestTotals.get(domain) || 0);
};
// For each route, compute the total activity weight across all resolved domains
// so we can distribute route-level throughput/connections. Prefer live domain
// request rates from SmartProxy 27.8+, falling back to lifetime counters.
const routeTotalRequests = new Map<string, number>();
for (const [domain, routeKeys] of domainToRoutes) {
const reqs = getDomainWeight(domain);
for (const routeKey of routeKeys) {
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
}
}
// Aggregate metrics per domain using request-count-proportional splitting
const domainAgg = new Map<string, {
activeConnections: number;
bytesInPerSec: number;
bytesOutPerSec: number;
routeCount: number;
requestCount: number;
requestsPerSecond: number;
requestsLastMinute: number;
}>();
// Track which routes are accounted for
const accountedRoutes = new Set<string>();
for (const [domain, routeNames] of domainToRoutes) {
for (const [domain, routeKeys] of domainToRoutes) {
const domainReqs = getDomainWeight(domain);
const requestRate = domainRequestRates.get(domain);
let totalConns = 0;
let totalIn = 0;
let totalOut = 0;
for (const routeName of routeNames) {
accountedRoutes.add(routeName);
const conns = connectionsByRoute.get(routeName) || 0;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
// Count how many resolved domains share this route
let domainsInRoute = 0;
for (const [, routes] of domainToRoutes) {
if (routes.includes(routeName)) domainsInRoute++;
}
const share = Math.max(domainsInRoute, 1);
totalConns += conns / share;
totalIn += tp.in / share;
totalOut += tp.out / share;
for (const routeKey of routeKeys) {
const conns = connectionsByRoute.get(routeKey) || 0;
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
const routeTotal = routeTotalRequests.get(routeKey) || 0;
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
totalConns += conns * share;
totalIn += tp.in * share;
totalOut += tp.out * share;
}
domainAgg.set(domain, {
activeConnections: Math.round(totalConns),
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeNames.length,
routeCount: routeKeys.length,
requestCount: domainRequestTotals.get(domain) || 0,
requestsPerSecond: requestRate?.perSecond ?? 0,
requestsLastMinute: requestRate?.lastMinute ?? 0,
});
}
// Include routes with no domain config (fallback: use route name)
for (const [routeName, activeConns] of connectionsByRoute) {
if (accountedRoutes.has(routeName)) continue;
if (routeDomains.has(routeName)) continue; // has domains but no traffic matched
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
const existing = domainAgg.get(routeName);
if (existing) {
existing.activeConnections += activeConns;
existing.bytesInPerSec += tp.in;
existing.bytesOutPerSec += tp.out;
existing.routeCount++;
} else {
domainAgg.set(routeName, {
activeConnections: activeConns,
bytesInPerSec: tp.in,
bytesOutPerSec: tp.out,
routeCount: 1,
});
}
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
@@ -843,14 +861,25 @@ export class MetricsManager {
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
requestCount: data.requestCount,
requestsPerSecond: data.requestsPerSecond,
requestsLastMinute: data.requestsLastMinute,
}))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
.sort((a, b) => {
if (hasLiveDomainRates) {
return (b.requestsPerSecond - a.requestsPerSecond) ||
(b.requestsLastMinute - a.requestsLastMinute) ||
((b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
}
return (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond);
});
return {
connectionsByIP,
throughputRate,
topIPs,
topIPsByBandwidth,
topASNs,
totalDataTransferred,
throughputHistory,
throughputByIP,
@@ -864,6 +893,60 @@ export class MetricsManager {
}, 1000); // 1s cache — matches typical dashboard poll interval
}
private async buildTopASNs(
observedIps: string[],
allIPData: Map<string, { count: number; bwIn: number; bwOut: number }>,
): Promise<IAsnActivity[]> {
const manager = this.dcRouter.securityPolicyManager;
if (!manager || observedIps.length === 0) {
return [];
}
const intelligenceRecords = await manager.listIpIntelligence({
ipAddresses: observedIps,
limit: Math.max(100, observedIps.length),
});
const asnActivity = new Map<number, IAsnActivity>();
for (const record of intelligenceRecords) {
if (typeof record.asn !== 'number') continue;
const ipData = allIPData.get(record.ipAddress);
if (!ipData) continue;
const existing = asnActivity.get(record.asn);
const activity = existing || {
asn: record.asn,
organization: record.asnOrg || record.registrantOrg || `AS${record.asn}`,
country: record.countryCode || record.country || record.registrantCountry || null,
activeConnections: 0,
ipCount: 0,
bytesInPerSecond: 0,
bytesOutPerSecond: 0,
sampleIps: [],
};
activity.activeConnections += ipData.count;
activity.bytesInPerSecond += ipData.bwIn;
activity.bytesOutPerSecond += ipData.bwOut;
activity.ipCount++;
if (activity.sampleIps.length < 5) {
activity.sampleIps.push(record.ipAddress);
}
asnActivity.set(record.asn, activity);
}
return [...asnActivity.values()]
.sort((a, b) => {
const connectionDiff = b.activeConnections - a.activeConnections;
if (connectionDiff !== 0) return connectionDiff;
const bandwidthA = a.bytesInPerSecond + a.bytesOutPerSecond;
const bandwidthB = b.bytesInPerSecond + b.bytesOutPerSecond;
return bandwidthB - bandwidthA;
})
.slice(0, 10);
}
// --- Time-series helpers ---
private static minuteKey(ts: number = Date.now()): number {
@@ -1006,4 +1089,4 @@ export class MetricsManager {
return { queries };
}
}
}
+9 -15
View File
@@ -3,7 +3,6 @@ import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import * as handlers from './handlers/index.js';
import * as interfaces from '../../ts_interfaces/index.js';
import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js';
export class OpsServer {
public dcRouterRef: DcRouter;
@@ -12,9 +11,9 @@ export class OpsServer {
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
public typedrouter = new plugins.typedrequest.TypedRouter();
// Auth-enforced routers — middleware validates identity before any handler runs
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
// Grouped routers. Handlers enforce auth explicitly with per-endpoint scopes.
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity?: interfaces.data.IIdentity; apiToken?: string } }>();
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity?: interfaces.data.IIdentity; apiToken?: string } }>();
// Handler instances
public adminHandler!: handlers.AdminHandler;
@@ -38,6 +37,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;
@@ -71,16 +71,6 @@ export class OpsServer {
this.adminHandler = new handlers.AdminHandler(this);
await this.adminHandler.initialize();
// viewRouter middleware: requires valid identity (any logged-in user)
this.viewRouter.addMiddleware(async (typedRequest) => {
await requireValidIdentity(this.adminHandler, typedRequest.request);
});
// adminRouter middleware: requires admin identity
this.adminRouter.addMiddleware(async (typedRequest) => {
await requireAdminIdentity(this.adminHandler, typedRequest.request);
});
// Connect auth routers to the main typedrouter
this.typedrouter.addTypedRouter(this.viewRouter);
this.typedrouter.addTypedRouter(this.adminRouter);
@@ -106,11 +96,15 @@ 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');
}
public async stop() {
if (this.adminHandler) {
await this.adminHandler.stop();
}
// Clean up log handler streams and push destination before stopping the server
if (this.logsHandler) {
this.logsHandler.cleanup();
@@ -119,4 +113,4 @@ export class OpsServer {
await this.server.stop();
}
}
}
}
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handler for the singleton `AcmeConfigDoc`.
@@ -20,29 +21,11 @@ export class AcmeConfigHandler {
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+406 -120
View File
@@ -8,19 +8,34 @@ export interface IJwtData {
expiresAt: number;
}
type TAdminUser = {
id: string;
username: string;
email?: string;
name?: string;
role: string;
status?: 'active' | 'disabled';
authSources?: Array<'local' | 'idp.global'>;
};
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
// JWT instance
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
// Simple in-memory user storage (in production, use proper database)
// Ephemeral bootstrap users. DB-backed instances may use these only until the
// database is ready and the first persistent admin account has been created.
private users = new Map<string, {
id: string;
username: string;
password: string;
role: string;
}>();
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
private ownsIdpClient = false;
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
@@ -32,6 +47,14 @@ export class AdminHandler {
this.initializeDefaultUsers();
this.registerHandlers();
}
public async stop(): Promise<void> {
if (this.ownsIdpClient) {
await this.idpClient?.stop();
}
this.idpClient = undefined;
this.ownsIdpClient = false;
}
private async initializeJwt(): Promise<void> {
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
@@ -43,65 +66,232 @@ export class AdminHandler {
}
private initializeDefaultUsers(): void {
// Add default admin user
const username = process.env.DCROUTER_ADMIN_USERNAME || 'admin';
const configuredPassword = process.env.DCROUTER_ADMIN_PASSWORD;
const password = configuredPassword || plugins.crypto.randomBytes(24).toString('base64url');
const adminId = plugins.uuid.v4();
this.users.set(adminId, {
id: adminId,
username: 'admin',
password: 'admin',
username,
password,
role: 'admin',
});
if (!configuredPassword) {
console.warn(`DCRouter generated one-time admin password for ${username}: ${password}`);
}
}
/**
* Return a safe projection of the users Map — excludes password fields.
* Return a safe projection of the active user source — excludes password fields.
* Used by UsersHandler to serve the admin-only listUsers endpoint.
*/
public listUsers(): Array<{ id: string; username: string; role: string }> {
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (accountState.hasPersistentAdmin) {
const accounts = await accountState.store!.listAccounts();
return accounts.map((accountArg) => this.accountToUser(accountArg));
}
return Array.from(this.users.values()).map((user) => ({
id: user.id,
username: user.username,
role: user.role,
}));
}
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
const accountState = await this.getPersistentAccountState();
const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
return {
dbEnabled: accountState.dbEnabled,
dbReady: accountState.dbReady,
hasPersistentAdmin: accountState.hasPersistentAdmin,
needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
ephemeralAdminAvailable: bootstrapAvailable,
idpGlobalConfigured: this.isIdpGlobalConfigured(),
};
}
public async createInitialAdminUser(optionsArg: {
email: string;
name?: string;
password: string;
enableIdpGlobalAuth?: boolean;
}): Promise<interfaces.requests.IReq_CreateInitialAdminUser['response']> {
const store = this.getAccountStore();
if (!store) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (await store.hasActiveAdminAccount()) {
throw new plugins.typedrequest.TypedResponseError('initial admin already exists');
}
const password = String(optionsArg.password || '');
if (!password) {
throw new plugins.typedrequest.TypedResponseError('password is required');
}
const email = String(optionsArg.email || '').trim();
const authSources: Array<'local' | 'idp.global'> = ['local'];
if (optionsArg.enableIdpGlobalAuth) {
authSources.push('idp.global');
}
try {
const account = await store.createAccount({
email,
name: String(optionsArg.name || '').trim() || email,
role: 'admin',
authSources,
password,
});
const user = this.accountToUser(account);
return {
success: true,
identity: await this.createIdentityForUser(user),
user,
};
} catch (error) {
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
}
}
public async createUser(optionsArg: {
email: string;
name?: string;
role: interfaces.requests.TUserManagementRole;
password: string;
enableIdpGlobalAuth?: boolean;
}): Promise<interfaces.requests.IReq_CreateUser['response']> {
const store = this.getAccountStore();
if (!store) {
return { success: false, message: 'database is not ready' };
}
if (!(await store.hasActiveAdminAccount())) {
return { success: false, message: 'initial admin bootstrap is required before creating users' };
}
const role = optionsArg.role;
if (role !== 'admin' && role !== 'user') {
return { success: false, message: 'role must be admin or user' };
}
const password = String(optionsArg.password || '');
if (!password) {
return { success: false, message: 'password is required' };
}
const authSources: Array<'local' | 'idp.global'> = ['local'];
if (optionsArg.enableIdpGlobalAuth) {
authSources.push('idp.global');
}
try {
const email = String(optionsArg.email || '').trim();
const account = await store.createAccount({
email,
name: String(optionsArg.name || '').trim() || email,
role,
authSources,
password,
});
return { success: true, user: this.accountToUser(account) };
} catch (error) {
return { success: false, message: (error as Error).message || 'failed to create user' };
}
}
public async deleteUser(optionsArg: {
id: string;
requestingUserId: string;
}): Promise<interfaces.requests.IReq_DeleteUser['response']> {
const store = this.getAccountStore();
if (!store) {
return { success: false, message: 'database is not ready' };
}
if (!(await store.hasActiveAdminAccount())) {
return { success: false, message: 'initial admin bootstrap is required before deleting users' };
}
const id = String(optionsArg.id || '').trim();
if (!id) {
return { success: false, message: 'user id is required' };
}
if (id === optionsArg.requestingUserId) {
return { success: false, message: 'cannot delete the current user' };
}
const account = await store.getAccountById(id);
if (!account) {
return { success: false, message: 'user not found' };
}
if (account.role === 'admin' && account.status === 'active') {
const activeAdmins = (await store.listAccounts()).filter(
(accountArg) => accountArg.role === 'admin' && accountArg.status === 'active',
);
if (activeAdmins.length <= 1) {
return { success: false, message: 'cannot delete the last active admin' };
}
}
const doc = await plugins.idpSdkServer.IdpSdkAccountDoc.findById(id);
if (!doc) {
return { success: false, message: 'user not found' };
}
await doc.delete();
return { success: true };
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminBootstrapStatus>(
'getAdminBootstrapStatus',
async (_dataArg) => this.getBootstrapStatus()
)
);
this.opsServerRef.adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
'createInitialAdminUser',
async (dataArg) => {
const isAdmin = await this.adminIdentityGuard.exec({ identity: dataArg.identity });
if (!isAdmin) {
throw new plugins.typedrequest.TypedResponseError('admin identity required');
}
return this.createInitialAdminUser({
email: dataArg.email,
name: dataArg.name,
password: dataArg.password,
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
});
}
)
);
// Admin Login Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg) => {
try {
// Find user by username and password
let user: { id: string; username: string; password: string; role: string } | null = null;
for (const [_, userData] of this.users) {
if (userData.username === dataArg.username && userData.password === dataArg.password) {
user = userData;
break;
}
}
const user = await this.authenticateUser({
username: dataArg.username,
password: dataArg.password,
authSource: dataArg.authSource,
});
if (!user) {
throw new plugins.typedrequest.TypedResponseError('login failed');
}
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
const jwt = await this.smartjwtInstance.createJWT({
userId: user.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
return {
identity: {
jwt,
userId: user.id,
name: user.username,
expiresAt: expiresAtTimestamp,
role: user.role,
type: 'user',
},
identity: await this.createIdentityForUser(user),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) {
@@ -118,8 +308,10 @@ export class AdminHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
async (dataArg) => {
// In a real implementation, you might want to blacklist the JWT
// For now, just return success
const identity = await this.validateIdentity(dataArg.identity);
if (!identity) {
throw new plugins.typedrequest.TypedResponseError('identity is not valid');
}
return {
success: true,
};
@@ -132,53 +324,8 @@ export class AdminHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity',
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return {
valid: false,
};
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check if expired
if (jwtData.expiresAt < Date.now()) {
return {
valid: false,
};
}
// Check if logged in
if (jwtData.status !== 'loggedIn') {
return {
valid: false,
};
}
// Find user
const user = this.users.get(jwtData.userId);
if (!user) {
return {
valid: false,
};
}
return {
valid: true,
identity: {
jwt: dataArg.identity.jwt,
userId: user.id,
name: user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
},
};
} catch (error) {
return {
valid: false,
};
}
const identity = await this.validateIdentity(dataArg.identity);
return identity ? { valid: true, identity } : { valid: false };
}
)
);
@@ -191,36 +338,7 @@ export class AdminHandler {
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return false;
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check expiration
if (jwtData.expiresAt < Date.now()) {
return false;
}
// Check status
if (jwtData.status !== 'loggedIn') {
return false;
}
// Verify data hasn't been tampered with
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
return false;
}
if (dataArg.identity.userId !== jwtData.userId) {
return false;
}
return true;
} catch (error) {
return false;
}
return Boolean(await this.validateIdentity(dataArg.identity));
},
{
failedHint: 'identity is not valid',
@@ -235,18 +353,186 @@ export class AdminHandler {
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
// First check if identity is valid
const isValid = await this.validIdentityGuard.exec(dataArg);
if (!isValid) {
return false;
}
// Check if user has admin role
return dataArg.identity.role === 'admin';
const identity = await this.validateIdentity(dataArg.identity);
return identity?.role === 'admin';
},
{
failedHint: 'user is not admin',
name: 'adminIdentityGuard',
}
);
}
public async validateIdentity(
identityArg?: interfaces.data.IIdentity,
): Promise<interfaces.data.IIdentity | null> {
if (!identityArg?.jwt) {
return null;
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(identityArg.jwt);
if (jwtData.expiresAt < Date.now()) {
return null;
}
if (jwtData.status !== 'loggedIn') {
return null;
}
if (identityArg.expiresAt !== jwtData.expiresAt) {
return null;
}
if (identityArg.userId !== jwtData.userId) {
return null;
}
const user = await this.resolveUser(jwtData.userId);
if (!user) {
return null;
}
if (identityArg.role && identityArg.role !== user.role) {
return null;
}
return {
jwt: identityArg.jwt,
userId: user.id,
name: user.name || user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
};
} catch {
return null;
}
}
private async authenticateUser(optionsArg: {
username: string;
password: string;
authSource?: interfaces.requests.TAdminLoginAuthSource;
}): Promise<TAdminUser | null> {
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (accountState.hasPersistentAdmin) {
const authService = new plugins.idpSdkServer.AccountAuthService({
store: accountState.store!,
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
});
const result = await authService.authenticate({
email: optionsArg.username,
password: optionsArg.password,
authSource: optionsArg.authSource || 'auto',
});
return result ? this.accountToUser(result.account) : null;
}
for (const [_, userData] of this.users) {
if (userData.username === optionsArg.username && userData.password === optionsArg.password) {
return userData;
}
}
return null;
}
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
return null;
}
if (accountState.hasPersistentAdmin) {
const account = await accountState.store!.getAccountById(userIdArg);
if (!account || account.status !== 'active') {
return null;
}
return this.accountToUser(account);
}
return this.users.get(userIdArg) || null;
}
private async getPersistentAccountState(): Promise<{
dbEnabled: boolean;
dbReady: boolean;
store: plugins.idpSdkServer.SmartdataAccountStore | null;
hasPersistentAdmin: boolean;
}> {
const dbEnabled = this.isPersistenceEnabled();
const store = dbEnabled ? this.getAccountStore() : null;
const dbReady = !!store;
const hasPersistentAdmin = store ? await store.hasActiveAdminAccount() : false;
return { dbEnabled, dbReady, store, hasPersistentAdmin };
}
private isPersistenceEnabled(): boolean {
return this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
}
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
if (!this.isPersistenceEnabled()) {
return null;
}
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
if (!dcRouterDb?.isReady()) {
return null;
}
if (!this.accountStore) {
this.accountStore = new plugins.idpSdkServer.SmartdataAccountStore({
smartdataDb: dcRouterDb.getDb(),
});
}
return this.accountStore;
}
private getIdpClient(): Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'> | undefined {
const configuredClient = this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient;
if (configuredClient) {
return configuredClient;
}
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
if (!this.idpClient) {
this.idpClient = baseUrl
? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl })
: new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions);
this.ownsIdpClient = true;
}
return this.idpClient;
}
private isIdpGlobalConfigured(): boolean {
return true;
}
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
return {
id: accountArg.id,
username: accountArg.email,
email: accountArg.email,
name: accountArg.name,
role: accountArg.role,
status: accountArg.status,
authSources: accountArg.authSources,
};
}
private async createIdentityForUser(userArg: TAdminUser): Promise<interfaces.data.IIdentity> {
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
const jwt = await this.smartjwtInstance.createJWT({
userId: userArg.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
return {
jwt,
userId: userArg.id,
name: userArg.name || userArg.username,
expiresAt: expiresAtTimestamp,
role: userArg.role,
type: 'user',
};
}
}
+28 -1
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class ApiTokenHandler {
constructor(private opsServerRef: OpsServer) {
@@ -17,6 +18,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
'createApiToken',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -25,7 +31,8 @@ export class ApiTokenHandler {
dataArg.name,
dataArg.scopes,
dataArg.expiresInDays ?? null,
dataArg.identity.userId,
auth.userId,
dataArg.policy,
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
@@ -37,6 +44,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
'listApiTokens',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:read',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { tokens: [] };
@@ -51,6 +63,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
'revokeApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -66,6 +83,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
'rollApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -84,6 +106,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
'toggleApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
+37 -17
View File
@@ -3,6 +3,7 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
import { logger } from '../../logger.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
@@ -26,21 +27,33 @@ 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> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
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 +61,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);
}
)
@@ -198,12 +214,11 @@ export class CertificateHandler {
try {
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
if (rustStatus) {
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
if (rustStatus.issuer) issuer = rustStatus.issuer;
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
status = rustStatus.status;
if (rustStatus.expiresAt > 0) {
expiryDate = new Date(rustStatus.expiresAt).toISOString();
}
if (rustStatus.source) issuer = rustStatus.source;
status = rustStatus.isValid ? 'valid' : 'expired';
}
} catch {
// Rust bridge may not support this command yet — ignore
@@ -275,6 +290,11 @@ export class CertificateHandler {
}
}
if (backoffInfo && status !== 'valid' && status !== 'expiring') {
status = 'failed';
error = error || backoffInfo.lastError;
}
certificates.push({
domain,
routeNames: info.routeNames,
+3
View File
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class ConfigHandler {
constructor(private opsServerRef: OpsServer) {
@@ -17,6 +18,7 @@ export class ConfigHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'config:read' });
const config = await this.getConfiguration();
return {
config,
@@ -206,6 +208,7 @@ export class ConfigHandler {
hubDomain: riCfg?.hubDomain || null,
tlsMode,
connectedEdgeIps,
performance: riCfg?.performance,
};
return {
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD + connection-test handlers for DnsProviderDoc.
@@ -20,29 +21,11 @@ export class DnsProviderHandler {
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handlers for DnsRecordDoc.
@@ -17,29 +18,11 @@ export class DnsRecordHandler {
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handlers for DomainDoc.
@@ -17,29 +18,11 @@ export class DomainHandler {
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD + DNS provisioning handler for email domains.
@@ -19,29 +20,11 @@ export class EmailDomainHandler {
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private get manager() {
+12 -19
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class EmailOpsHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +19,7 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
'getAllEmails',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
const emails = this.getAllQueueEmails();
return { emails };
}
@@ -29,6 +31,7 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
const email = this.getEmailDetail(dataArg.emailId);
return { email };
}
@@ -42,13 +45,17 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
'resendEmail',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'emails:write',
requireAdminIdentity: true,
});
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return { success: false, error: 'Email server not available' };
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(dataArg.emailId);
const item = emailServer.getQueueItem(dataArg.emailId);
if (!item) {
return { success: false, error: 'Email not found in queue' };
@@ -82,22 +89,10 @@ export class EmailOpsHandler {
*/
private getAllQueueEmails(): interfaces.requests.IEmail[] {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
if (!emailServer) {
return [];
}
const queue = emailServer.deliveryQueue;
const queueMap = (queue as any).queue as Map<string, any>;
if (!queueMap) {
return [];
}
const emails: interfaces.requests.IEmail[] = [];
for (const [id, item] of queueMap.entries()) {
emails.push(this.mapQueueItemToEmail(item));
}
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
// Sort by createdAt descending (newest first)
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
@@ -110,12 +105,10 @@ export class EmailOpsHandler {
*/
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
if (!emailServer) {
return null;
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(emailId);
const item = emailServer.getQueueItem(emailId);
if (!item) {
return null;
+2 -1
View File
@@ -18,4 +18,5 @@ export * from './dns-provider.handler.js';
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 './email-domain.handler.js';
export * from './workhoster.handler.js';
+3
View File
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { logBuffer, baseLogger } from '../../logger.js';
import { requireOpsAuth } from '../helpers/auth.js';
// Module-level singleton: the log push destination is added once and reuses
// the current OpsServer reference so it survives OpsServer restarts without
@@ -40,6 +41,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
'getRecentLogs',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'logs:read' });
const logs = await this.getRecentLogs(
dataArg.level,
dataArg.category,
@@ -63,6 +65,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
'getLogStream',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'logs:read' });
// Create a virtual stream for log streaming
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class NetworkTargetHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -14,29 +15,11 @@ export class NetworkTargetHandler {
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+31
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class RadiusHandler {
constructor(private opsServerRef: OpsServer) {
@@ -19,6 +20,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
'getRadiusClients',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -43,6 +45,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
'setRadiusClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -64,6 +70,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
'removeRadiusClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -88,6 +98,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
'getVlanMappings',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -124,6 +135,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
'setVlanMapping',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -156,6 +171,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
'removeVlanMapping',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -177,6 +196,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
'updateVlanConfig',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -209,6 +232,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
'testVlanAssignment',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -243,6 +267,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
'getRadiusSessions',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -292,6 +317,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
'disconnectRadiusSession',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -317,6 +346,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
'getRadiusAccountingSummary',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -354,6 +384,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
'getRadiusStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class RemoteIngressHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +19,7 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
'getRemoteIngresses',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { edges: [] };
@@ -29,6 +31,7 @@ export class RemoteIngressHandler {
...e,
secret: '********', // Never expose secrets via API
effectiveListenPorts: manager.getEffectiveListenPorts(e),
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(e),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
};
@@ -45,6 +48,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
'createRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -77,6 +84,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
'deleteRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -102,6 +113,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
'updateRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -133,6 +148,7 @@ export class RemoteIngressHandler {
...edge,
secret: '********',
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(edge),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
},
@@ -146,6 +162,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'regenerateRemoteIngressSecret',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -173,6 +193,7 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
'getRemoteIngressStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!tunnelManager) {
return { statuses: [] };
@@ -187,6 +208,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
'getRemoteIngressConnectionToken',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { success: false, message: 'RemoteIngress not configured' };
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class RouteManagementHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -18,31 +19,11 @@ export class RouteManagementHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
// Try JWT identity first
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 */ }
}
// Try API token
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
@@ -87,12 +68,12 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.updateRoute(dataArg.id, {
const result = await manager.updateRoute(dataArg.id, {
route: dataArg.route as any,
enabled: dataArg.enabled,
metadata: dataArg.metadata,
});
return { success: ok, message: ok ? undefined : 'Route not found' };
return result;
},
),
);
@@ -107,8 +88,7 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.deleteRoute(dataArg.id);
return { success: ok, message: ok ? undefined : 'Route not found' };
return manager.deleteRoute(dataArg.id);
},
),
);
@@ -123,8 +103,7 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Route not found' };
return manager.toggleRoute(dataArg.id, dataArg.enabled);
},
),
);
+152 -5
View File
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class SecurityHandler {
constructor(private opsServerRef: OpsServer) {
@@ -17,6 +18,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
'getSecurityMetrics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const metrics = await this.collectSecurityMetrics();
return {
metrics: {
@@ -43,6 +45,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
'getActiveConnections',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
id: conn.id,
@@ -50,19 +53,21 @@ export class SecurityHandler {
localAddress: conn.destination.ip,
startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any,
state: conn.status === 'active' ? 'connected' : conn.status as any,
bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: (conn as any)._throughputOut || 0,
connectionCount: conn.bytesTransferred || 1,
}));
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
const summary = {
total: connectionInfos.length,
total: totalConnections,
byProtocol: connectionInfos.reduce((acc, conn) => {
acc[conn.protocol] = (acc[conn.protocol] || 0) + 1;
acc[conn.protocol] = (acc[conn.protocol] || 0) + (conn.connectionCount || 1);
return acc;
}, {} as { [protocol: string]: number }),
byState: connectionInfos.reduce((acc, conn) => {
acc[conn.state] = (acc[conn.state] || 0) + 1;
acc[conn.state] = (acc[conn.state] || 0) + (conn.connectionCount || 1);
return acc;
}, {} as { [state: string]: number }),
};
@@ -80,6 +85,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
// Get network stats from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
@@ -97,6 +103,7 @@ export class SecurityHandler {
throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
topASNs: networkStats.topASNs,
totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
@@ -104,6 +111,8 @@ export class SecurityHandler {
requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [],
frontendProtocols: networkStats.frontendProtocols || null,
backendProtocols: networkStats.backendProtocols || null,
};
}
@@ -113,6 +122,7 @@ export class SecurityHandler {
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
topIPsByBandwidth: [],
topASNs: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
@@ -120,6 +130,8 @@ export class SecurityHandler {
requestsPerSecond: 0,
requestsTotal: 0,
backends: [],
frontendProtocols: null,
backendProtocols: null,
};
}
)
@@ -130,6 +142,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
'getRateLimitStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const status = await this.getRateLimitStatus(dataArg.domain, dataArg.ip);
const limits: interfaces.data.IRateLimitInfo[] = status.limits.map(limit => ({
domain: limit.identifier,
@@ -151,6 +164,140 @@ export class SecurityHandler {
}
)
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityBlockRules>(
'listSecurityBlockRules',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { rules: manager ? await manager.listBlockRules() : [] };
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListIpIntelligence>(
'listIpIntelligence',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return {
records: manager
? await manager.listIpIntelligence({
ipAddresses: dataArg.ipAddresses,
limit: dataArg.limit,
})
: [],
};
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCompiledSecurityPolicy>(
'getCompiledSecurityPolicy',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return {
policy: manager
? await manager.compilePolicy()
: { blockedIps: [], blockedCidrs: [] },
};
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityPolicyAudit>(
'listSecurityPolicyAudit',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { events: manager ? await manager.listAuditEvents(dataArg.limit || 100) : [] };
},
),
);
const adminRouter = this.opsServerRef.adminRouter;
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityBlockRule>(
'createSecurityBlockRule',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'security:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const rule = await manager.createBlockRule({
type: dataArg.type,
value: dataArg.value,
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
}, auth.userId);
return { success: true, rule };
},
),
);
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityBlockRule>(
'updateSecurityBlockRule',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'security:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const rule = await manager.updateBlockRule(dataArg.id, {
value: dataArg.value,
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
}, auth.userId);
return rule ? { success: true, rule } : { success: false, message: 'Rule not found' };
},
),
);
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityBlockRule>(
'deleteSecurityBlockRule',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'security:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const success = await manager.deleteBlockRule(dataArg.id, auth.userId);
return { success, message: success ? undefined : 'Rule not found' };
},
),
);
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshIpIntelligence>(
'refreshIpIntelligence',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'security:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const record = await manager.refreshIpIntelligence(dataArg.ipAddress);
return record
? { success: true, record }
: { success: false, message: 'IP address is invalid or not public' };
},
),
);
}
private async collectSecurityMetrics(): Promise<{
@@ -335,4 +482,4 @@ export class SecurityHandler {
limits: [],
};
}
}
}
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class SourceProfileHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -14,29 +15,11 @@ export class SourceProfileHandler {
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+52 -7
View File
@@ -4,6 +4,7 @@ import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
import { SecurityLogger } from '../../security/classes.securitylogger.js';
import { commitinfo } from '../../00_commitinfo_data.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class StatsHandler {
constructor(private opsServerRef: OpsServer) {
@@ -19,6 +20,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
'getServerStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const stats = await this.collectServerStats();
return {
stats: {
@@ -42,6 +44,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
'getEmailStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return {
@@ -81,6 +84,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
'getDnsStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const dnsServer = this.opsServerRef.dcRouterRef.dnsServer;
if (!dnsServer) {
return {
@@ -118,6 +122,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
'getQueueStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
const queues: interfaces.data.IQueueStatus[] = [];
@@ -146,6 +151,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
'getHealthStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const health = await this.checkHealthStatus();
return {
health: {
@@ -171,6 +177,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
'getCombinedMetrics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const sections = dataArg.sections || {
server: true,
email: true,
@@ -302,6 +309,7 @@ export class StatsHandler {
startTime: 0,
bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0,
connectionCount: count,
});
}
@@ -326,6 +334,7 @@ export class StatsHandler {
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
topASNs: stats.topASNs || [],
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
@@ -530,13 +539,49 @@ export class StatsHandler {
nextRetry?: number;
}>;
}> {
// TODO: Implement actual queue status collection
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return {
pending: 0,
active: 0,
failed: 0,
retrying: 0,
items: [],
};
}
const queueStats = emailServer.getQueueStats();
const items = emailServer.getQueueItems()
.sort((a, b) => {
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
return right - left;
})
.slice(0, 50)
.map((item) => {
const emailLike = item.processingResult;
const recipients = Array.isArray(emailLike?.to)
? emailLike.to
: Array.isArray(emailLike?.email?.to)
? emailLike.email.to
: [];
const subject = emailLike?.subject || emailLike?.email?.subject || '';
return {
id: item.id,
recipient: recipients[0] || '',
subject,
status: item.status,
attempts: item.attempts,
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
};
});
return {
pending: 0,
active: 0,
failed: 0,
retrying: 0,
items: [],
pending: queueStats.status.pending,
active: queueStats.status.processing,
failed: queueStats.status.failed,
retrying: queueStats.status.deferred,
items,
};
}
@@ -600,4 +645,4 @@ export class StatsHandler {
],
};
}
}
}
+10 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class TargetProfileHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -14,29 +15,11 @@ export class TargetProfileHandler {
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
@@ -86,8 +69,11 @@ export class TargetProfileHandler {
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
createdBy: userId,
});
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
return { success: true, id };
},
),
@@ -109,6 +95,7 @@ export class TargetProfileHandler {
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
});
// Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
+47 -4
View File
@@ -1,9 +1,10 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
* Handler for OpsServer user accounts. Registers on adminRouter,
* so admin middleware enforces auth + role check before the handler runs.
* User data is owned by AdminHandler; this handler just exposes a safe
* projection of it via TypedRequest.
@@ -16,15 +17,57 @@ export class UsersHandler {
private registerHandlers(): void {
const router = this.opsServerRef.adminRouter;
// List users (admin-only, read-only)
// List users (admin-only)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
'listUsers',
async (_dataArg) => {
const users = this.opsServerRef.adminHandler.listUsers();
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'users:read',
requireAdminIdentity: true,
requireAdminToken: true,
});
const users = await this.opsServerRef.adminHandler.listUsers();
return { users };
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateUser>(
'createUser',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'users:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
return this.opsServerRef.adminHandler.createUser({
email: dataArg.email,
name: dataArg.name,
role: dataArg.role,
password: dataArg.password,
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
});
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteUser>(
'deleteUser',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'users:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
return this.opsServerRef.adminHandler.deleteUser({
id: dataArg.id,
requestingUserId: auth.userId,
});
},
),
);
}
}
+35
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class VpnHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +19,7 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
'getVpnClients',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'vpn:read' });
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { clients: [] };
@@ -49,6 +51,7 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
'getVpnStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'vpn:read' });
const manager = this.opsServerRef.dcRouterRef.vpnManager;
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
if (!manager) {
@@ -84,6 +87,7 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnConnectedClients>(
'getVpnConnectedClients',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'vpn:read' });
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { connectedClients: [] };
@@ -98,6 +102,8 @@ export class VpnHandler {
bytesSent: c.bytesSent,
bytesReceived: c.bytesReceived,
transport: c.transportType,
remoteAddr: c.remoteAddr,
sourceIp: manager.getClientSourceIp(c.registeredClientId || c.clientId),
})),
};
},
@@ -111,6 +117,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
'createVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -168,6 +178,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVpnClient>(
'updateVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -198,6 +212,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
'deleteVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -218,6 +236,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
'enableVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -238,6 +260,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
'disableVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -258,6 +284,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
'rotateVpnClientKey',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -281,6 +311,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
'exportVpnClientConfig',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -301,6 +335,7 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
'getVpnClientTelemetry',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'vpn:read' });
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
+649
View File
@@ -0,0 +1,649 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
type TAuthContext = {
userId: string;
isAdmin: boolean;
token?: interfaces.data.IStoredApiToken;
};
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<TAuthContext> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return { userId: auth.userId, isAdmin: auth.isAdmin, token: auth.token };
}
private async requireAdmin(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
scope: interfaces.data.TApiTokenScope = 'gateway-clients:write',
): Promise<string> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope,
requireAdminIdentity: true,
requireAdminToken: true,
});
return auth.userId;
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
'getGatewayCapabilities',
async (dataArg) => {
await this.requireAuth(dataArg, 'gateway-clients:read');
return { capabilities: this.getGatewayCapabilities() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientContext>(
'getGatewayClientContext',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
return {
context: this.getGatewayClientContext(auth),
capabilities: this.getGatewayCapabilities(),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListGatewayClients>(
'listGatewayClients',
async (dataArg) => {
await this.requireAdmin(dataArg, 'gateway-clients:read');
return { gatewayClients: await this.listManagedGatewayClients() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClient>(
'createGatewayClient',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
try {
const gatewayClient = await manager.createClient({
id: dataArg.id,
type: dataArg.type,
name: dataArg.name,
description: dataArg.description,
hostnamePatterns: dataArg.hostnamePatterns,
allowedRouteTargets: dataArg.allowedRouteTargets,
capabilities: dataArg.capabilities,
createdBy: userId,
});
return { success: true, gatewayClient };
} catch (error) {
return { success: false, message: (error as Error).message };
}
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateGatewayClient>(
'updateGatewayClient',
async (dataArg) => {
await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
const gatewayClient = await manager.updateClient(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
hostnamePatterns: dataArg.hostnamePatterns,
allowedRouteTargets: dataArg.allowedRouteTargets,
capabilities: dataArg.capabilities,
enabled: dataArg.enabled,
});
return gatewayClient
? { success: true, gatewayClient }
: { success: false, message: 'Gateway client not found' };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteGatewayClient>(
'deleteGatewayClient',
async (dataArg) => {
await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
const success = await manager.deleteClient(dataArg.id);
return { success, message: success ? undefined : 'Gateway client not found' };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClientToken>(
'createGatewayClientToken',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg, 'tokens:manage');
const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId);
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!gatewayClient || !gatewayClient.enabled) {
return { success: false, message: 'Gateway client not found or disabled' };
}
if (!tokenManager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await tokenManager.createToken(
dataArg.name?.trim() || `${gatewayClient.name} Token`,
['gateway-clients:read', 'gateway-clients:write'],
dataArg.expiresInDays ?? null,
userId,
{
role: 'gatewayClient',
scopes: ['gateway-clients:read', 'gateway-clients:write'],
gatewayClient: { type: gatewayClient.type, id: gatewayClient.id },
hostnamePatterns: gatewayClient.hostnamePatterns,
allowedRouteTargets: gatewayClient.allowedRouteTargets,
capabilities: gatewayClient.capabilities,
},
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDomains>(
'getGatewayClientDomains',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
this.assertCapability(auth, 'readDomains');
return { domains: await this.listGatewayClientDomains(auth, dataArg.gatewayClientId) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDnsRecords>(
'getGatewayClientDnsRecords',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
this.assertCapability(auth, 'readDnsRecords');
return { records: await this.listGatewayClientDnsRecords(auth, dataArg.gatewayClientId) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkHosterDomains>(
'getWorkHosterDomains',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'workhosters:read');
this.assertCapability(auth, 'readDomains');
return { domains: await this.listGatewayClientDomains(auth) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncGatewayClientRoute>(
'syncGatewayClientRoute',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:write');
this.assertCapability(auth, 'syncRoutes');
return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete);
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppRoute>(
'syncWorkAppRoute',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'workhosters:write');
this.assertCapability(auth, 'syncRoutes');
const ownership: interfaces.data.IGatewayClientOwnership = {
gatewayClientType: dataArg.ownership.workHosterType,
gatewayClientId: dataArg.ownership.workHosterId,
appId: dataArg.ownership.workAppId,
hostname: dataArg.ownership.hostname,
};
return await this.syncGatewayClientRoute(auth, ownership, dataArg.route, dataArg.enabled, dataArg.delete);
},
),
);
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 auth = 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, auth.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 getGatewayClientContext(auth: TAuthContext): interfaces.data.IGatewayClientContext {
const policy = auth.token?.policy;
const role = auth.isAdmin ? 'admin' : policy?.role || 'operator';
return {
role,
scopes: auth.token?.scopes || ['*'],
gatewayClient: policy?.gatewayClient,
hostnamePatterns: policy?.hostnamePatterns || [],
allowedRouteTargets: policy?.allowedRouteTargets || [],
capabilities: policy?.capabilities || {},
};
}
private async listManagedGatewayClients(): Promise<interfaces.data.IGatewayClient[]> {
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return [];
const clients = await manager.listClients();
const tokens = this.opsServerRef.dcRouterRef.apiTokenManager?.listTokens() || [];
return clients.map((client) => ({
...client,
tokenCount: tokens.filter((token) => token.policy?.gatewayClient?.id === client.id).length,
}));
}
private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string {
return [
ownership.workHosterType,
ownership.workHosterId,
ownership.workAppId,
ownership.hostname,
].map((part) => part.trim()).join(':');
}
private assertCapability(
auth: TAuthContext,
capability: keyof NonNullable<interfaces.data.IApiTokenPolicy['capabilities']>,
): void {
if (auth.isAdmin) return;
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
if (policy.capabilities?.[capability] === true) return;
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
}
private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
const policyClient = auth.token?.policy?.gatewayClient;
if (!policyClient) return requestedId;
if (requestedId && requestedId !== policyClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot access another gateway client');
}
return policyClient.id;
}
private resolveGatewayClientOwnership(
auth: TAuthContext,
ownership: interfaces.data.IGatewayClientOwnership,
): Required<interfaces.data.IGatewayClientOwnership> {
const policy = auth.token?.policy;
if (policy?.role === 'gatewayClient') {
if (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (ownership.gatewayClientType && ownership.gatewayClientType !== policy.gatewayClient.type) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (ownership.gatewayClientId && ownership.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
return {
gatewayClientType: policy.gatewayClient.type,
gatewayClientId: policy.gatewayClient.id,
appId: ownership.appId,
hostname: ownership.hostname,
};
}
if (!ownership.gatewayClientType || !ownership.gatewayClientId) {
throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing type or id');
}
return ownership as Required<interfaces.data.IGatewayClientOwnership>;
}
private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
}
}
private assertRouteTargetsAllowed(auth: TAuthContext, route?: interfaces.data.IDcRouterRouteConfig): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient' || !route) return;
const allowedTargets = policy.allowedRouteTargets || [];
if (allowedTargets.length === 0) {
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
}
const targets = ((route.action as any)?.targets || []) as Array<{ host?: string; port?: number }>;
for (const target of targets) {
const host = String(target.host || '').trim().toLowerCase();
const port = Number(target.port);
const allowed = allowedTargets.some((allowedTarget) => {
return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
});
if (!allowed) {
throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`);
}
}
}
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
const normalizedHostname = hostname.trim().toLowerCase();
if (!normalizedHostname) return false;
for (const pattern of patterns) {
const normalizedPattern = pattern.trim().toLowerCase();
if (!normalizedPattern) continue;
if (normalizedPattern === normalizedHostname) return true;
if (normalizedPattern.startsWith('*.')) {
const suffix = normalizedPattern.slice(2);
if (!normalizedHostname.endsWith(`.${suffix}`)) continue;
const prefix = normalizedHostname.slice(0, -(suffix.length + 1));
if (prefix && !prefix.includes('.')) return true;
}
}
return false;
}
private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] {
const domains = (route.match as any)?.domains;
if (Array.isArray(domains)) {
return domains.map((domain) => String(domain).trim().toLowerCase()).filter(Boolean);
}
if (typeof domains === 'string') {
return domains.split(',').map((domain) => domain.trim().toLowerCase()).filter(Boolean);
}
return [];
}
private getOwnedRoutes(gatewayClientId?: string): interfaces.data.IMergedRoute[] {
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) return [];
return manager.getMergedRoutes().routes.filter((route) => {
const metadata = route.metadata;
if (!metadata) return false;
const ownerType = metadata.ownerType;
const isGatewayOwned = ownerType === 'gatewayClient' || ownerType === 'workhoster';
if (!isGatewayOwned) return false;
const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId;
return gatewayClientId ? routeGatewayClientId === gatewayClientId : true;
});
}
private async listGatewayClientDomains(
auth: TAuthContext,
requestedGatewayClientId?: string,
): Promise<interfaces.data.IGatewayClientDomain[]> {
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return [];
const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
const routeHostnames = ownedRoutes.flatMap((route) => this.getRouteHostnames(route.route));
const docs = await dnsManager.listDomains();
return docs
.filter((domainDoc) => {
if (!auth.token?.policy || auth.token.policy.role !== 'gatewayClient') return true;
return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name));
})
.map((domainDoc) => {
const domain = dnsManager.toPublicDomain(domainDoc);
const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId);
const serviceCount = routeHostnames.filter((hostname) => this.isHostnameInDomain(hostname, domain.name)).length;
return {
...domain,
serviceCount,
managePath: `/domains/${domain.id}`,
capabilities: {
canCreateSubdomains: canManageDnsRecords,
canManageDnsRecords,
canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy),
canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager),
},
} satisfies interfaces.data.IGatewayClientDomain;
});
}
private async listGatewayClientDnsRecords(
auth: TAuthContext,
requestedGatewayClientId?: string,
): Promise<interfaces.data.IGatewayClientDnsRecord[]> {
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return [];
const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
const domains = await dnsManager.listDomains();
const records: interfaces.data.IGatewayClientDnsRecord[] = [];
for (const route of ownedRoutes) {
const metadata = route.metadata;
if (!metadata) continue;
const gatewayClientType = metadata.gatewayClientType || metadata.workHosterType || 'custom';
const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId || '';
const appId = metadata.gatewayClientAppId || metadata.workAppId || '';
for (const hostname of this.getRouteHostnames(route.route)) {
if (auth.token?.policy?.role === 'gatewayClient' && !this.matchesHostnamePatterns(hostname, auth.token.policy.hostnamePatterns || [])) {
continue;
}
const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name));
const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : [];
const matchingRecords = domainRecords.filter((record) => record.name === hostname);
if (matchingRecords.length === 0) {
records.push({
id: `missing:${hostname}`,
domainId: domainDoc?.id || '',
domainName: domainDoc?.name,
name: hostname,
type: 'MISSING',
value: '',
ttl: 0,
source: 'local',
status: 'missing',
gatewayClientType,
gatewayClientId: routeGatewayClientId,
appId,
hostname,
routeId: route.id,
managePath: domainDoc ? `/domains/${domainDoc.id}/dns` : '/domains',
createdAt: route.createdAt || 0,
updatedAt: route.updatedAt || 0,
createdBy: '',
});
continue;
}
for (const recordDoc of matchingRecords) {
const record = dnsManager.toPublicRecord(recordDoc);
records.push({
...record,
domainName: domainDoc?.name,
status: 'active',
gatewayClientType,
gatewayClientId: routeGatewayClientId,
appId,
hostname,
routeId: route.id,
managePath: `/dns-records/${record.id}`,
});
}
}
}
return records;
}
private isHostnameInDomain(hostname: string, domainName: string): boolean {
const normalizedHostname = hostname.trim().toLowerCase();
const normalizedDomainName = domainName.trim().toLowerCase();
return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`);
}
private async syncGatewayClientRoute(
auth: TAuthContext,
ownership: interfaces.data.IGatewayClientOwnership,
route?: interfaces.data.IDcRouterRouteConfig,
enabled?: boolean,
deleteRoute?: boolean,
): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
this.assertGatewayClientOwnership(auth, resolvedOwnership);
this.assertRouteTargetsAllowed(auth, route);
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
if (deleteRoute) {
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 (!route) {
return { success: false, message: 'route is required unless delete=true' };
}
const metadata: interfaces.data.IRouteMetadata = {
ownerType: 'gatewayClient',
gatewayClientType: resolvedOwnership.gatewayClientType,
gatewayClientId: resolvedOwnership.gatewayClientId,
gatewayClientAppId: resolvedOwnership.appId,
workHosterType: resolvedOwnership.gatewayClientType,
workHosterId: resolvedOwnership.gatewayClientId,
workAppId: resolvedOwnership.appId,
externalKey,
};
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
if (existingRoute) {
const result = await manager.updateRoute(existingRoute.id, {
route: normalizedRoute,
enabled: enabled ?? true,
metadata,
});
return result.success
? { success: true, action: 'updated', routeId: existingRoute.id }
: { success: false, message: result.message };
}
const routeId = await manager.createRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
return { success: true, action: 'created', routeId };
}
private buildGatewayClientExternalKey(ownership: Required<interfaces.data.IGatewayClientOwnership>): string {
return [
ownership.gatewayClientType,
ownership.gatewayClientId,
ownership.appId,
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;
}
private normalizeGatewayClientRoute(
route: interfaces.data.IDcRouterRouteConfig,
ownership: Required<interfaces.data.IGatewayClientOwnership>,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };
if (!normalizedRoute.name) {
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
}
return normalizedRoute;
}
}
+91
View File
@@ -0,0 +1,91 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export interface IAuthRequest {
identity?: interfaces.data.IIdentity;
apiToken?: string;
}
export interface IAuthRequirement {
scope?: interfaces.data.TApiTokenScope;
requireAdminIdentity?: boolean;
requireAdminToken?: boolean;
}
export interface IAuthContext {
type: 'identity' | 'apiToken';
userId: string;
role?: string;
isAdmin: boolean;
scopes: interfaces.data.TApiTokenScope[];
identity?: interfaces.data.IIdentity;
token?: interfaces.data.IStoredApiToken;
}
const typedAuthError = (messageArg: string) => {
return new plugins.typedrequest.TypedResponseError(messageArg);
};
export async function requireOpsAuth(
opsServerRefArg: OpsServer,
requestArg: IAuthRequest,
requirementArg: IAuthRequirement = {},
): Promise<IAuthContext> {
let identityNeedsAdmin = false;
let tokenNeedsAdmin = false;
let tokenNeedsScope = false;
if (requestArg.identity?.jwt) {
const identity = await opsServerRefArg.adminHandler.validateIdentity(requestArg.identity);
if (identity) {
const isAdmin = identity.role === 'admin';
if (!requirementArg.requireAdminIdentity || isAdmin) {
return {
type: 'identity',
userId: identity.userId,
role: identity.role,
isAdmin,
scopes: [],
identity,
};
}
identityNeedsAdmin = true;
}
}
if (requestArg.apiToken) {
const tokenManager = opsServerRefArg.dcRouterRef.apiTokenManager;
const token = tokenManager ? await tokenManager.validateToken(requestArg.apiToken) : null;
if (token) {
if (requirementArg.requireAdminToken && token.policy?.role !== 'admin') {
tokenNeedsAdmin = true;
} else if (requirementArg.scope && !tokenManager!.hasScope(token, requirementArg.scope)) {
tokenNeedsScope = true;
} else {
const scopes = token.policy?.role === 'admin'
? ['*' as interfaces.data.TApiTokenScope]
: Array.from(new Set([...(token.scopes || []), ...(token.policy?.scopes || [])]));
return {
type: 'apiToken',
userId: token.createdBy,
role: token.policy?.role || 'operator',
isAdmin: token.policy?.role === 'admin',
scopes,
token,
};
}
}
}
if (tokenNeedsScope) {
throw typedAuthError('insufficient scope');
}
if (tokenNeedsAdmin) {
throw typedAuthError('admin API token required');
}
if (identityNeedsAdmin) {
throw typedAuthError('admin identity required');
}
throw typedAuthError('unauthorized');
}
+16 -9
View File
@@ -1,13 +1,13 @@
// node native
import * as dns from 'dns';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as http from 'http';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import * as tls from 'tls';
import * as util from 'util';
import * as dns from 'node:dns';
import * as fs from 'node:fs';
import * as crypto from 'node:crypto';
import * as http from 'node:http';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
import * as tls from 'node:tls';
import * as util from 'node:util';
export {
dns,
@@ -41,6 +41,13 @@ export {
typedsocket,
}
// @idp.global scope
import * as idpSdkServer from '@idp.global/sdk/server';
export {
idpSdkServer,
}
// @push.rocks scope
import * as projectinfo from '@push.rocks/projectinfo';
import * as qenv from '@push.rocks/qenv';
+45 -102
View File
@@ -1,8 +1,6 @@
# @serve.zone/dcrouter
The core DcRouter package — a unified datacenter gateway orchestrator. 🚀
This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
The `ts/` directory is the main dcrouter runtime package. It exposes the `DcRouter` orchestrator, `IDcRouterOptions`, `runCli()`, and the server-side exports that matter when you want to boot the full router stack from code.
## Issue Reporting and Security
@@ -14,7 +12,19 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter
```
## Usage
## Core Exports
| Export | Purpose |
| --- | --- |
| `DcRouter` | Main orchestrator for proxying, DNS, email, VPN, RADIUS, remote ingress, DB, and OpsServer |
| `IDcRouterOptions` | Top-level configuration shape |
| `runCli()` | Bootstrap helper; uses OCI env-driven config when `DCROUTER_MODE=OCI_CONTAINER` |
| `UnifiedEmailServer` and smartmta types | Re-exported email server primitives |
| `RadiusServer` and related types | RADIUS server runtime exports |
| `RemoteIngressManager` and `TunnelManager` | Remote ingress orchestration exports |
| `IHttp3Config` | HTTP/3 configuration for qualifying HTTPS routes |
## Quick Start
```typescript
import { DcRouter } from '@serve.zone/dcrouter';
@@ -23,116 +33,49 @@ const router = new DcRouter({
smartProxyConfig: {
routes: [
{
name: 'web-app',
match: { domains: ['example.com'], ports: [443] },
name: 'local-app',
match: {
domains: ['localhost'],
ports: [18080],
},
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
}
}
targets: [{ host: '127.0.0.1', port: 3001 }],
},
},
],
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
}
},
opsServerPort: 3000,
});
await router.start();
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
// Graceful shutdown
await router.stop();
```
## Module Structure
## What `DcRouter` Manages
```
ts/
├── index.ts # Main exports (DcRouter, re-exported smartmta types)
├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions
├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler
├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager
├── logger.ts # Structured logging utility
├── paths.ts # Centralized data directory paths
├── plugins.ts # All dependency imports
├── cache/ # Cache database (smartdata + LocalTsmDb)
│ ├── classes.cachedb.ts # CacheDb singleton
│ ├── classes.cachecleaner.ts # TTL-based cleanup
│ └── documents/ # Cached document models
├── config/ # Configuration utilities
├── errors/ # Error classes and retry logic
├── http3/ # HTTP/3 (QUIC) route augmentation
│ ├── index.ts # Barrel export
│ └── http3-route-augmentation.ts # Pure utility: augmentRoutesWithHttp3(), IHttp3Config
├── monitoring/ # MetricsManager (SmartMetrics integration)
├── opsserver/ # OpsServer dashboard + API handlers
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
│ └── handlers/ # TypedRequest handlers by domain
│ ├── admin.handler.ts # Auth (login/logout/verify)
│ ├── stats.handler.ts # Statistics + health
│ ├── config.handler.ts # Configuration (read-only)
│ ├── logs.handler.ts # Log retrieval
│ ├── email.handler.ts # Email operations
│ ├── certificate.handler.ts # Certificate management
│ ├── radius.handler.ts # RADIUS management
│ ├── remoteingress.handler.ts # Remote ingress edge + token management
│ ├── route-management.handler.ts # Programmatic route CRUD
│ ├── api-token.handler.ts # API token management
│ └── security.handler.ts # Security metrics + connections
├── radius/ # RADIUS server integration
├── remoteingress/ # Remote ingress hub integration
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
│ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
├── security/ # Security utilities
├── sms/ # SMS integration
└── storage/ # StorageManager (filesystem/custom/memory)
```
- SmartProxy for HTTP/HTTPS/TCP routes
- `UnifiedEmailServer` for SMTP ingress and delivery when `emailConfig` is present
- DB-backed managers for routes, API tokens, target profiles, domains, records, ACME config, and email domains when the DB is enabled
- embedded authoritative DNS and DoH route generation from `dnsNsDomains` and `dnsScopes`
- VPN, RADIUS, and remote ingress services when their config blocks are enabled
- OpsServer and the dashboard, which start on every boot
## Exports
## Important Runtime Behavior
```typescript
// Main class
export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js';
- The DB is enabled by default and uses an embedded local database when no external MongoDB URL is provided.
- System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
- API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
- Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
- `runCli()` is the supported code-level bootstrap entrypoint; the package does not expose a separate npm `bin` command.
// Re-exported from smartmta
export { UnifiedEmailServer } from '@push.rocks/smartmta';
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
## Use Another Module When...
// RADIUS
export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
// Remote Ingress
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
// HTTP/3
export type { IHttp3Config } from './http3/index.js';
```
## Key Classes
### `DcRouter`
The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
| Config Section | Service Started | Package |
|----------------|----------------|---------|
| `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
| `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
| `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
| `http3` | HTTP/3 route augmentation (enabled by default) | built-in |
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
### `RemoteIngressManager`
Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
### `TunnelManager`
Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
| Need | Module |
| --- | --- |
| A higher-level client SDK for a running router | `@serve.zone/dcrouter-apiclient` or `@serve.zone/dcrouter/apiclient` |
| Raw TypedRequest request/data contracts | `@serve.zone/dcrouter-interfaces` or `@serve.zone/dcrouter/interfaces` |
| The standalone migration runner | `@serve.zone/dcrouter-migrations` |
| The browser dashboard module boundary | `@serve.zone/dcrouter-web` |
## License and Legal Information
@@ -148,7 +91,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
@@ -2,6 +2,10 @@ import * as plugins from '../plugins.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc } from '../db/index.js';
interface IRemoteIngressFirewallConfig {
blockedIps?: string[];
}
/**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
*/
@@ -31,6 +35,7 @@ function extractPorts(portRange: number | Array<number | { from: number; to: num
export class RemoteIngressManager {
private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = [];
private firewallConfig?: IRemoteIngressFirewallConfig;
constructor() {
}
@@ -69,6 +74,13 @@ export class RemoteIngressManager {
this.routes = routes;
}
/**
* Set the full desired firewall snapshot pushed to all edges.
*/
public setFirewallConfig(firewallConfig?: IRemoteIngressFirewallConfig): void {
this.firewallConfig = firewallConfig;
}
/**
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
@@ -305,8 +317,8 @@ export class RemoteIngressManager {
* Get the list of allowed edges (enabled only) for the Rust hub.
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
*/
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = [];
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
@@ -315,6 +327,7 @@ export class RemoteIngressManager {
secret: edge.secret,
listenPorts: this.getEffectiveListenPorts(edge),
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
});
}
}
+25 -5
View File
@@ -9,6 +9,7 @@ export interface ITunnelManagerConfig {
certPem?: string;
keyPem?: string;
};
performance?: import('../../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
}
/**
@@ -20,6 +21,7 @@ export class TunnelManager {
private config: ITunnelManagerConfig;
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
private syncChain: Promise<void> = Promise.resolve();
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
this.manager = manager;
@@ -66,7 +68,8 @@ export class TunnelManager {
tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
});
...(this.config.performance ? { performance: this.config.performance } : {}),
} as any);
// Send allowed edges to the hub
await this.syncAllowedEdges();
@@ -107,20 +110,23 @@ export class TunnelManager {
if (existing) {
existing.activeTunnels = rustEdge.activeStreams;
existing.lastHeartbeat = Date.now();
this.applyRustStatus(existing, rustEdge);
// Update peer address if available from Rust hub
if (rustEdge.peerAddr) {
existing.publicIp = rustEdge.peerAddr;
}
} else {
// Missed edgeConnected event — add entry
this.edgeStatuses.set(rustEdge.edgeId, {
const status: IRemoteIngressStatus = {
edgeId: rustEdge.edgeId,
connected: true,
publicIp: rustEdge.peerAddr || null,
activeTunnels: rustEdge.activeStreams,
lastHeartbeat: Date.now(),
connectedAt: rustEdge.connectedAt * 1000,
});
};
this.applyRustStatus(status, rustEdge);
this.edgeStatuses.set(rustEdge.edgeId, status);
}
}
@@ -137,8 +143,22 @@ export class TunnelManager {
* Call this after creating/deleting/updating edges.
*/
public async syncAllowedEdges(): Promise<void> {
const edges = this.manager.getAllowedEdges();
await this.hub.updateAllowedEdges(edges);
const run = this.syncChain.catch(() => {}).then(async () => {
const edges = this.manager.getAllowedEdges();
await this.hub.updateAllowedEdges(edges as any);
});
this.syncChain = run;
await run;
}
private applyRustStatus(status: IRemoteIngressStatus, rustEdge: any): void {
status.transportMode = rustEdge.transportMode;
status.fallbackUsed = rustEdge.fallbackUsed;
status.performance = rustEdge.performance;
status.flowControl = rustEdge.flowControl;
status.queues = rustEdge.queues;
status.traffic = rustEdge.traffic;
status.udp = rustEdge.udp;
}
/**
@@ -0,0 +1,530 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../db/index.js';
import type {
IIpIntelligenceRecord,
ISecurityBlockRule,
ISecurityCompiledPolicy,
ISecurityPolicyAuditEvent,
TSecurityBlockRuleMatchMode,
TSecurityBlockRuleType,
} from '../../ts_interfaces/data/security-policy.js';
export interface ISecurityPolicyManagerOptions {
intelligenceRefreshMs?: number;
onPolicyChanged?: () => void | Promise<void>;
}
export interface IRemoteIngressFirewallSnapshot {
blockedIps: string[];
}
const OBSERVED_IP_QUEUE_LIMIT = 512;
const OBSERVED_IP_BATCH_LIMIT = 20;
const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
export class SecurityPolicyManager {
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
cacheTtl: 24 * 60 * 60 * 1000,
ipIntelligenceTimeout: 5_000,
});
private readonly intelligenceRefreshMs: number;
private readonly inFlightObservations = new Map<string, Promise<void>>();
private readonly queuedObservations = new Set<string>();
private readonly observationQueue: string[] = [];
private readonly lastQueuedAt = new Map<string, number>();
private activeQueuedObservations = 0;
private queueDrainScheduled = false;
private isStopping = false;
private readonly onPolicyChanged?: () => void | Promise<void>;
constructor(options: ISecurityPolicyManagerOptions = {}) {
this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000;
this.onPolicyChanged = options.onPolicyChanged;
}
public async start(): Promise<void> {
logger.log('info', 'SecurityPolicyManager started');
}
public async stop(): Promise<void> {
this.isStopping = true;
this.observationQueue.length = 0;
this.queuedObservations.clear();
await this.smartNetwork.stop();
}
public async observeIps(ips: string[]): Promise<void> {
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
}
public queueObservedIps(ips: string[]): void {
if (this.isStopping) return;
const now = Date.now();
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
for (const ip of uniqueIps.slice(0, OBSERVED_IP_BATCH_LIMIT)) {
if (!this.isPublicIp(ip)) continue;
if (this.inFlightObservations.has(ip) || this.queuedObservations.has(ip)) continue;
const lastQueuedAt = this.lastQueuedAt.get(ip);
if (lastQueuedAt && now - lastQueuedAt < OBSERVED_IP_REQUEUE_THROTTLE_MS) continue;
if (this.observationQueue.length >= OBSERVED_IP_QUEUE_LIMIT) {
const droppedIp = this.observationQueue.shift();
if (droppedIp) this.queuedObservations.delete(droppedIp);
}
this.observationQueue.push(ip);
this.queuedObservations.add(ip);
this.lastQueuedAt.set(ip, now);
}
this.pruneQueuedIpMemory(now);
this.scheduleQueueDrain();
}
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
const ip = this.normalizeIp(ipAddress);
if (!ip || !this.isPublicIp(ip)) {
return;
}
const existingObservation = this.inFlightObservations.get(ip);
if (existingObservation) {
await existingObservation;
if (!options.force) return;
}
const observationPromise = this.performObserveIp(ip, options).finally(() => {
if (this.inFlightObservations.get(ip) === observationPromise) {
this.inFlightObservations.delete(ip);
}
});
this.inFlightObservations.set(ip, observationPromise);
await observationPromise;
}
private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
try {
const now = Date.now();
let doc = await IpIntelligenceDoc.findByIp(ip);
if (doc && !options.force && now - doc.updatedAt < this.intelligenceRefreshMs) {
if (now - doc.lastSeenAt > 60_000) {
doc.lastSeenAt = now;
doc.seenCount = (doc.seenCount || 0) + 1;
await doc.save();
}
return;
}
const intelligence = await this.smartNetwork.getIpIntelligence(ip);
if (!doc) {
doc = new IpIntelligenceDoc();
doc.ipAddress = ip;
doc.firstSeenAt = now;
}
Object.assign(doc, intelligence);
doc.lastSeenAt = now;
doc.updatedAt = now;
doc.seenCount = (doc.seenCount || 0) + 1;
await doc.save();
if (await this.matchesAnyReactiveRule(doc)) {
await this.notifyPolicyChanged();
}
} catch (err) {
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
}
}
public async listBlockRules(): Promise<ISecurityBlockRule[]> {
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
}
public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
const limit = Number.isInteger(options.limit) && options.limit! > 0
? Math.min(options.limit!, 500)
: undefined;
let docs: IpIntelligenceDoc[];
if (options.ipAddresses?.length) {
const ips = [...new Set(options.ipAddresses.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
const results = await Promise.all(ips.map((ip) => IpIntelligenceDoc.findByIp(ip)));
docs = results.filter(Boolean) as IpIntelligenceDoc[];
} else {
docs = await IpIntelligenceDoc.findAll();
}
const sortedDocs = docs.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
return (limit ? sortedDocs.slice(0, limit) : sortedDocs).map((doc) => this.intelligenceFromDoc(doc));
}
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
const ip = this.normalizeIp(ipAddress);
if (!ip || !this.isPublicIp(ip)) {
return null;
}
await this.observeIp(ip, { force: true });
const doc = await IpIntelligenceDoc.findByIp(ip);
return doc ? this.intelligenceFromDoc(doc) : null;
}
private scheduleQueueDrain(): void {
if (this.queueDrainScheduled || this.isStopping) return;
this.queueDrainScheduled = true;
setTimeout(() => {
this.queueDrainScheduled = false;
this.drainObservationQueue();
}, 0);
}
private drainObservationQueue(): void {
if (this.isStopping) return;
while (
this.activeQueuedObservations < OBSERVED_IP_QUEUE_CONCURRENCY &&
this.observationQueue.length > 0
) {
const ip = this.observationQueue.shift()!;
this.queuedObservations.delete(ip);
this.activeQueuedObservations++;
void this.observeIp(ip)
.catch(() => undefined)
.finally(() => {
this.activeQueuedObservations--;
if (this.observationQueue.length > 0) {
this.scheduleQueueDrain();
}
});
}
}
private pruneQueuedIpMemory(now: number): void {
if (this.lastQueuedAt.size <= OBSERVED_IP_QUEUE_LIMIT * 2) return;
for (const [ip, lastQueuedAt] of this.lastQueuedAt) {
if (now - lastQueuedAt > OBSERVED_IP_REQUEUE_THROTTLE_MS * 2) {
this.lastQueuedAt.delete(ip);
}
}
}
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
id: doc.id,
action: doc.action,
actor: doc.actor,
details: doc.details,
createdAt: doc.createdAt,
}));
}
private intelligenceFromDoc(doc: IpIntelligenceDoc): IIpIntelligenceRecord {
return {
ipAddress: doc.ipAddress,
asn: doc.asn,
asnOrg: doc.asnOrg,
registrantOrg: doc.registrantOrg,
registrantCountry: doc.registrantCountry,
networkRange: doc.networkRange,
networkCidrs: doc.networkCidrs,
abuseContact: doc.abuseContact,
country: doc.country,
countryCode: doc.countryCode,
city: doc.city,
latitude: doc.latitude,
longitude: doc.longitude,
accuracyRadius: doc.accuracyRadius,
timezone: doc.timezone,
firstSeenAt: doc.firstSeenAt,
lastSeenAt: doc.lastSeenAt,
updatedAt: doc.updatedAt,
seenCount: doc.seenCount,
};
}
public async createBlockRule(input: {
type: TSecurityBlockRuleType;
value: string;
matchMode?: TSecurityBlockRuleMatchMode;
reason?: string;
enabled?: boolean;
}, actor = 'system'): Promise<ISecurityBlockRule> {
const now = Date.now();
const doc = new SecurityBlockRuleDoc();
doc.id = plugins.uuid.v4();
doc.type = input.type;
doc.value = input.value.trim();
doc.matchMode = input.matchMode;
doc.reason = input.reason;
doc.enabled = input.enabled ?? true;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = actor;
await doc.save();
await this.writeAudit('createBlockRule', actor, { rule: this.ruleFromDoc(doc) });
await this.notifyPolicyChanged();
return this.ruleFromDoc(doc);
}
public async updateBlockRule(id: string, patch: Partial<Pick<ISecurityBlockRule, 'value' | 'matchMode' | 'reason' | 'enabled'>>, actor = 'system'): Promise<ISecurityBlockRule | null> {
const doc = await SecurityBlockRuleDoc.findById(id);
if (!doc) {
return null;
}
if (patch.value !== undefined) doc.value = patch.value.trim();
if (patch.matchMode !== undefined) doc.matchMode = patch.matchMode;
if (patch.reason !== undefined) doc.reason = patch.reason;
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
doc.updatedAt = Date.now();
await doc.save();
await this.writeAudit('updateBlockRule', actor, { id, patch });
await this.notifyPolicyChanged();
return this.ruleFromDoc(doc);
}
public async deleteBlockRule(id: string, actor = 'system'): Promise<boolean> {
const doc = await SecurityBlockRuleDoc.findById(id);
if (!doc) {
return false;
}
await doc.delete();
await this.writeAudit('deleteBlockRule', actor, { id });
await this.notifyPolicyChanged();
return true;
}
public async compilePolicy(): Promise<ISecurityCompiledPolicy> {
const rules = await SecurityBlockRuleDoc.findEnabled();
const intelligenceDocs = await IpIntelligenceDoc.findAll();
const blockedIps = new Set<string>();
const blockedCidrs = new Set<string>();
for (const rule of rules) {
const normalizedValue = rule.value.trim();
if (!normalizedValue) continue;
if (rule.type === 'ip') {
const ip = this.normalizeIp(normalizedValue);
if (ip && plugins.net.isIP(ip)) blockedIps.add(ip);
continue;
}
if (rule.type === 'cidr') {
for (const cidr of this.normalizeNetworkEntries(normalizedValue)) {
blockedCidrs.add(cidr);
}
continue;
}
for (const doc of intelligenceDocs) {
if (!this.ruleMatchesIntelligence(rule, doc)) continue;
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)!);
}
}
}
return {
blockedIps: [...blockedIps].sort(),
blockedCidrs: [...blockedCidrs].sort(),
};
}
public async compileSmartProxyPolicy(): Promise<ISecurityCompiledPolicy> {
return await this.compilePolicy();
}
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 };
}
private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise<boolean> {
const rules = await SecurityBlockRuleDoc.findEnabled();
return rules.some((rule) => rule.type === 'asn' || rule.type === 'organization'
? this.ruleMatchesIntelligence(rule, doc)
: false);
}
private ruleMatchesIntelligence(rule: SecurityBlockRuleDoc, doc: IpIntelligenceDoc): boolean {
const value = rule.value.trim().toLowerCase();
if (!value) return false;
if (rule.type === 'asn') {
return String(doc.asn ?? '') === value.replace(/^as/i, '');
}
if (rule.type === 'organization') {
const candidates = [doc.asnOrg, doc.registrantOrg]
.filter(Boolean)
.map((candidate) => candidate!.toLowerCase());
if (rule.matchMode === 'exact') {
return candidates.some((candidate) => candidate === value);
}
return candidates.some((candidate) => candidate.includes(value));
}
return false;
}
private normalizeIp(ipAddress: string): string | undefined {
const ip = ipAddress.trim();
if (ip.startsWith('::ffff:')) {
return ip.slice('::ffff:'.length);
}
return plugins.net.isIP(ip) ? ip : undefined;
}
private normalizeCidr(value: string): string | undefined {
const [rawIp, rawPrefix] = value.trim().split('/');
if (!rawIp || !rawPrefix) return undefined;
const ip = this.normalizeIp(rawIp);
if (!ip) return undefined;
const prefix = Number(rawPrefix);
const maxPrefix = plugins.net.isIP(ip) === 4 ? 32 : 128;
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) return undefined;
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) {
const parts = ip.split('.').map((part) => Number(part));
const [a, b] = parts;
if (a === 10 || a === 127 || a === 0 || a >= 224) return false;
if (a === 100 && b >= 64 && b <= 127) return false;
if (a === 169 && b === 254) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && b === 168) return false;
return true;
}
if (family === 6) {
const lower = ip.toLowerCase();
if (lower === '::1' || lower === '::') return false;
if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return false;
return true;
}
return false;
}
private ruleFromDoc(doc: SecurityBlockRuleDoc): ISecurityBlockRule {
return {
id: doc.id,
type: doc.type,
value: doc.value,
matchMode: doc.matchMode,
enabled: doc.enabled,
reason: doc.reason,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
private async writeAudit(action: string, actor: string, details: Record<string, unknown>): Promise<void> {
const doc = new SecurityPolicyAuditDoc();
doc.id = plugins.uuid.v4();
doc.action = action;
doc.actor = actor;
doc.details = details;
doc.createdAt = Date.now();
await doc.save();
}
private async notifyPolicyChanged(): Promise<void> {
if (this.onPolicyChanged) {
await this.onPolicyChanged();
}
}
}
+7 -1
View File
@@ -18,4 +18,10 @@ export {
ThreatCategory,
type IScanResult,
type IContentScannerOptions
} from './classes.contentscanner.js';
} from './classes.contentscanner.js';
export {
SecurityPolicyManager,
type ISecurityPolicyManagerOptions,
type IRemoteIngressFirewallSnapshot,
} from './classes.security-policy-manager.js';
+415 -90
View File
@@ -19,6 +19,10 @@ export interface IVpnManagerConfig {
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Called when a live VPN client's real source IP changes. */
onClientSourceIpsChanged?: () => void;
/** Poll interval for live VPN client real source IP updates. Default: 10 seconds. */
clientSourceIpPollIntervalMs?: number;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
@@ -29,7 +33,7 @@ export interface IVpnManagerConfig {
/** Compute per-client AllowedIPs based on the client's target profile IDs.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
getClientAllowedIPs?: (targetProfileIds: string[], clientId?: string, sourceIp?: string) => Promise<string[]>;
/** Resolve per-client destination allow-list IPs from target profile IDs.
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
@@ -55,6 +59,11 @@ export class VpnManager {
private vpnServer?: plugins.smartvpn.VpnServer;
private clients: Map<string, VpnClientDoc> = new Map();
private serverKeys?: VpnServerKeysDoc;
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
private clientSourceIps = new Map<string, string>();
private clientSourceIpPollTimer?: ReturnType<typeof setInterval>;
private clientSourceIpRefreshInFlight = false;
constructor(config: IVpnManagerConfig) {
this.config = config;
@@ -88,6 +97,7 @@ export class VpnManager {
if (client.useHostIp) {
anyClientUsesHostIp = true;
}
this.normalizeClientRoutingSettings(client);
const entry: plugins.smartvpn.IClientEntry = {
clientId: client.clientId,
publicKey: client.noisePublicKey,
@@ -97,28 +107,27 @@ export class VpnManager {
assignedIp: client.assignedIp,
expiresAt: client.expiresAt,
security: this.buildClientSecurity(client),
useHostIp: client.useHostIp,
useDhcp: client.useDhcp,
staticIp: client.staticIp,
forceVlan: client.forceVlan,
vlanId: client.vlanId,
};
// Pass per-client bridge fields if present (for hybrid/bridge mode)
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
clientEntries.push(entry);
}
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
const serverEndpoint = this.getWireGuardServerEndpoint();
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
let configuredMode = this.config.forwardingMode ?? 'socket';
if (anyClientUsesHostIp && configuredMode === 'socket') {
configuredMode = 'hybrid';
const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
}
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
const forwardingMode = desiredForwardingMode;
const isBridge = forwardingMode === 'bridge';
this.resolvedForwardingMode = forwardingMode;
this.forwardingModeOverride = undefined;
// Create and start VpnServer
this.vpnServer = new plugins.smartvpn.VpnServer({
@@ -132,21 +141,21 @@ export class VpnManager {
: { default: 'forceTarget' as const, target: '127.0.0.1' };
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
listenAddr: '127.0.0.1:0', // Required by smartvpn, unused in wireguard-only mode
privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: forwardingMode as any,
transportMode: 'all',
transportMode: 'wireguard',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: !isBridge,
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
serverEndpoint: this.config.serverEndpoint
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
socketForwardProxyProtocolSource: 'remoteIp',
socketForwardProxyProtocolVpnMetadata: true,
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
serverEndpoint,
clientAllowedIPs: [subnet],
// Bridge-specific config
...(isBridge ? {
@@ -173,6 +182,9 @@ export class VpnManager {
}
}
await this.refreshClientSourceIps(false);
this.startClientSourceIpPolling();
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
@@ -180,15 +192,22 @@ export class VpnManager {
* Stop the VPN server.
*/
public async stop(): Promise<void> {
this.stopClientSourceIpPolling();
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
} catch {
// Ignore stop errors
}
this.vpnServer.stop();
await this.vpnServer.stop();
this.vpnServer = undefined;
}
const hadClientSourceIps = this.clientSourceIps.size > 0;
this.clientSourceIps.clear();
if (hadClientSourceIps) {
this.config.onClientSourceIpsChanged?.();
}
this.resolvedForwardingMode = undefined;
logger.log('info', 'VPN server stopped');
}
@@ -213,55 +232,52 @@ export class VpnManager {
throw new Error('VPN server not running');
}
await this.ensureForwardingModeForNextClient(opts.useHostIp === true);
const doc = new VpnClientDoc();
doc.clientId = opts.clientId;
doc.enabled = true;
doc.targetProfileIds = opts.targetProfileIds;
doc.description = opts.description;
doc.destinationAllowList = opts.destinationAllowList;
doc.destinationBlockList = opts.destinationBlockList;
doc.useHostIp = opts.useHostIp;
doc.useDhcp = opts.useDhcp;
doc.staticIp = opts.staticIp;
doc.forceVlan = opts.forceVlan;
doc.vlanId = opts.vlanId;
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
this.normalizeClientRoutingSettings(doc);
const bundle = await this.vpnServer.createClient({
clientId: opts.clientId,
description: opts.description,
clientId: doc.clientId,
description: doc.description,
security: this.buildClientSecurity(doc),
useHostIp: doc.useHostIp,
useDhcp: doc.useDhcp,
staticIp: doc.staticIp,
forceVlan: doc.forceVlan,
vlanId: doc.vlanId,
});
// Override AllowedIPs with per-client values based on target profiles
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
doc.targetProfileIds || [],
doc.clientId,
);
// Persist client entry (including WG private key for export/QR)
const doc = new VpnClientDoc();
doc.clientId = bundle.entry.clientId;
doc.enabled = bundle.entry.enabled ?? true;
doc.targetProfileIds = opts.targetProfileIds;
doc.description = bundle.entry.description;
doc.assignedIp = bundle.entry.assignedIp;
doc.noisePublicKey = bundle.entry.publicKey;
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
doc.expiresAt = bundle.entry.expiresAt;
if (opts.destinationAllowList !== undefined) {
doc.destinationAllowList = opts.destinationAllowList;
}
if (opts.destinationBlockList !== undefined) {
doc.destinationBlockList = opts.destinationBlockList;
}
if (opts.useHostIp !== undefined) {
doc.useHostIp = opts.useHostIp;
}
if (opts.useDhcp !== undefined) {
doc.useDhcp = opts.useDhcp;
}
if (opts.staticIp !== undefined) {
doc.staticIp = opts.staticIp;
}
if (opts.forceVlan !== undefined) {
doc.forceVlan = opts.forceVlan;
}
if (opts.vlanId !== undefined) {
doc.vlanId = opts.vlanId;
}
this.clients.set(doc.clientId, doc);
try {
await this.persistClient(doc);
@@ -276,12 +292,6 @@ export class VpnManager {
throw err;
}
// Sync per-client security to the running daemon
const security = this.buildClientSecurity(doc);
if (security.destinationPolicy) {
await this.vpnServer!.updateClient(doc.clientId, { security });
}
this.config.onClientChanged?.();
return bundle;
}
@@ -296,9 +306,11 @@ export class VpnManager {
await this.vpnServer.removeClient(clientId);
const doc = this.clients.get(clientId);
this.clients.delete(clientId);
this.clientSourceIps.delete(clientId);
if (doc) {
await doc.delete();
}
await this.reconcileForwardingMode();
this.config.onClientChanged?.();
}
@@ -336,6 +348,7 @@ export class VpnManager {
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.clientSourceIps.delete(clientId);
this.config.onClientChanged?.();
}
@@ -364,13 +377,15 @@ export class VpnManager {
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
this.normalizeClientRoutingSettings(client);
client.updatedAt = Date.now();
await this.persistClient(client);
// Sync per-client security to the running daemon
if (this.vpnServer) {
const security = this.buildClientSecurity(client);
await this.vpnServer.updateClient(clientId, { security });
const restarted = await this.reconcileForwardingMode();
if (!restarted) {
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
}
}
this.config.onClientChanged?.();
@@ -382,9 +397,14 @@ export class VpnManager {
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) throw new Error('VPN server not running');
const bundle = await this.vpnServer.rotateClientKey(clientId);
const client = this.clients.get(clientId);
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
client?.targetProfileIds || [],
clientId,
);
// Update persisted entry with new keys (including private key for export/QR)
const client = this.clients.get(clientId);
if (client) {
client.noisePublicKey = bundle.entry.publicKey;
client.wgPublicKey = bundle.entry.wgPublicKey || '';
@@ -415,15 +435,11 @@ export class VpnManager {
);
}
// Override AllowedIPs with per-client values based on target profiles
if (this.config.getClientAllowedIPs) {
const profileIds = persisted?.targetProfileIds || [];
const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
config = config.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
config = await this.rewriteWireGuardAllowedIPs(
config,
persisted?.targetProfileIds || [],
clientId,
);
}
return config;
@@ -455,6 +471,107 @@ export class VpnManager {
return this.vpnServer.listClients();
}
public getClientSourceIp(clientId: string): string | undefined {
return this.clientSourceIps.get(clientId);
}
public getClientSourceIpMap(): Map<string, string> {
return new Map(this.clientSourceIps);
}
public async refreshClientSourceIps(notifyOnChange = true): Promise<boolean> {
if (!this.vpnServer || this.clientSourceIpRefreshInFlight) {
return false;
}
this.clientSourceIpRefreshInFlight = true;
try {
const connectedClients = await this.vpnServer.listClients();
const nextSourceIps = new Map<string, string>();
const wireguardClientIds = new Set<string>();
for (const connectedClient of connectedClients) {
const clientId = connectedClient.registeredClientId || connectedClient.clientId;
if (!clientId) continue;
if (connectedClient.transportType === 'wireguard') {
wireguardClientIds.add(clientId);
}
const sourceIp = VpnManager.normalizeRemoteAddress(connectedClient.remoteAddr);
if (sourceIp) {
nextSourceIps.set(clientId, sourceIp);
}
}
if (wireguardClientIds.size > 0 && typeof (this.vpnServer as any).listWgPeers === 'function') {
try {
const wgPeers = await this.vpnServer.listWgPeers();
const endpointByPublicKey = new Map<string, string>();
for (const peer of wgPeers) {
const endpointIp = VpnManager.normalizeRemoteAddress(peer.endpoint);
if (peer.publicKey && endpointIp) {
endpointByPublicKey.set(peer.publicKey, endpointIp);
}
}
for (const client of this.clients.values()) {
if (nextSourceIps.has(client.clientId)) continue;
if (!wireguardClientIds.has(client.clientId)) continue;
if (!client.wgPublicKey) continue;
const endpointIp = endpointByPublicKey.get(client.wgPublicKey);
if (endpointIp) {
nextSourceIps.set(client.clientId, endpointIp);
}
}
} catch (err) {
logger.log('warn', `VPN: Failed to refresh WireGuard peer endpoints: ${(err as Error).message}`);
}
}
if (this.sameSourceIpMap(this.clientSourceIps, nextSourceIps)) {
return false;
}
this.clientSourceIps = nextSourceIps;
if (notifyOnChange) {
this.config.onClientSourceIpsChanged?.();
}
return true;
} catch (err) {
logger.log('warn', `VPN: Failed to refresh client source IPs: ${(err as Error).message}`);
return false;
} finally {
this.clientSourceIpRefreshInFlight = false;
}
}
public static normalizeRemoteAddress(remoteAddress?: string): string | undefined {
const remoteAddressString = remoteAddress?.trim();
if (!remoteAddressString) return undefined;
if (remoteAddressString.startsWith('[')) {
const closingBracketIndex = remoteAddressString.indexOf(']');
if (closingBracketIndex > 0) {
const bracketedIp = remoteAddressString.slice(1, closingBracketIndex);
return plugins.net.isIP(bracketedIp) ? bracketedIp : undefined;
}
}
if (plugins.net.isIP(remoteAddressString)) {
return remoteAddressString;
}
const lastColonIndex = remoteAddressString.lastIndexOf(':');
if (lastColonIndex > -1 && remoteAddressString.indexOf(':') === lastColonIndex) {
const host = remoteAddressString.slice(0, lastColonIndex);
if (plugins.net.isIP(host)) {
return host;
}
}
return undefined;
}
/**
* Get telemetry for a specific client.
*/
@@ -478,26 +595,28 @@ export class VpnManager {
/**
* Build per-client security settings for the smartvpn daemon.
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
* TargetProfile direct IP:port targets extend the effective allow-list.
*/
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
const security: plugins.smartvpn.IClientSecurity = {};
const basePolicy = this.getBaseDestinationPolicy(client);
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
// Merge with per-client explicit allow list
const mergedAllowList = [
...(client.destinationAllowList || []),
...profileDirectTargets,
];
const mergedAllowList = this.mergeDestinationLists(
basePolicy.allowList,
client.destinationAllowList,
profileDirectTargets,
);
const mergedBlockList = this.mergeDestinationLists(
basePolicy.blockList,
client.destinationBlockList,
);
security.destinationPolicy = {
default: 'forceTarget' as const,
target: '127.0.0.1',
default: basePolicy.default,
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
allowList: mergedAllowList.length ? mergedAllowList : undefined,
blockList: client.destinationBlockList,
blockList: mergedBlockList.length ? mergedBlockList : undefined,
};
return security;
@@ -510,13 +629,55 @@ export class VpnManager {
public async refreshAllClientSecurity(): Promise<void> {
if (!this.vpnServer) return;
for (const client of this.clients.values()) {
const security = this.buildClientSecurity(client);
if (security.destinationPolicy) {
await this.vpnServer.updateClient(client.clientId, { security });
}
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
}
}
private getWireGuardServerEndpoint(): string {
const endpoint = this.config.serverEndpoint?.trim();
if (!endpoint) {
throw new Error('vpnConfig.serverEndpoint is required when VPN is enabled');
}
if (endpoint.includes('://') || endpoint.includes('/')) {
throw new Error('vpnConfig.serverEndpoint must be a host or host:port, not a URL');
}
const host = endpoint.includes(':') ? endpoint.split(':')[0] : endpoint;
const lowerHost = host.toLowerCase();
if (
lowerHost === 'localhost'
|| lowerHost === '0.0.0.0'
|| lowerHost.startsWith('127.')
) {
throw new Error('vpnConfig.serverEndpoint must be reachable by VPN clients');
}
return endpoint.includes(':')
? endpoint
: `${endpoint}:${this.config.wgListenPort ?? 51820}`;
}
private async rewriteWireGuardAllowedIPs(
wireguardConfig: string,
targetProfileIds: string[],
clientId?: string,
): Promise<string> {
if (!this.config.getClientAllowedIPs) return wireguardConfig;
const allowedIPs = await this.config.getClientAllowedIPs(
targetProfileIds,
clientId,
clientId ? this.getClientSourceIp(clientId) : undefined,
);
const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`;
if (/^AllowedIPs\s*=.*$/m.test(wireguardConfig)) {
return wireguardConfig.replace(/^AllowedIPs\s*=.*$/m, allowedLine);
}
return `${wireguardConfig.trimEnd()}\n${allowedLine}\n`;
}
// ── Private helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
@@ -534,7 +695,7 @@ export class VpnManager {
const noiseKeys = await tempServer.generateKeypair();
const wgKeys = await tempServer.generateWgKeypair();
tempServer.stop();
await tempServer.stop();
const doc = stored || new VpnServerKeysDoc();
doc.noisePrivateKey = noiseKeys.privateKey;
@@ -550,6 +711,7 @@ export class VpnManager {
private async loadPersistedClients(): Promise<void> {
const docs = await VpnClientDoc.findAll();
for (const doc of docs) {
this.normalizeClientRoutingSettings(doc);
this.clients.set(doc.clientId, doc);
}
if (this.clients.size > 0) {
@@ -557,6 +719,169 @@ export class VpnManager {
}
}
private startClientSourceIpPolling(): void {
this.stopClientSourceIpPolling();
const pollIntervalMs = Math.max(1000, this.config.clientSourceIpPollIntervalMs ?? 10_000);
this.clientSourceIpPollTimer = setInterval(() => {
void this.refreshClientSourceIps().catch((err) => {
logger.log('warn', `VPN: Client source IP polling failed: ${err?.message || err}`);
});
}, pollIntervalMs);
this.clientSourceIpPollTimer.unref?.();
}
private stopClientSourceIpPolling(): void {
if (!this.clientSourceIpPollTimer) return;
clearInterval(this.clientSourceIpPollTimer);
this.clientSourceIpPollTimer = undefined;
}
private sameSourceIpMap(left: Map<string, string>, right: Map<string, string>): boolean {
if (left.size !== right.size) return false;
for (const [clientId, sourceIp] of left) {
if (right.get(clientId) !== sourceIp) return false;
}
return true;
}
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
return this.resolvedForwardingMode
?? this.forwardingModeOverride
?? this.config.forwardingMode
?? 'socket';
}
private hasHostIpClients(extraHostIpClient = false): boolean {
if (extraHostIpClient) {
return true;
}
for (const client of this.clients.values()) {
if (client.useHostIp) {
return true;
}
}
return false;
}
private getDesiredForwardingMode(hasHostIpClients = this.hasHostIpClients()): 'socket' | 'bridge' | 'hybrid' {
const configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
if (configuredMode !== 'socket') {
return configuredMode;
}
return hasHostIpClients ? 'hybrid' : 'socket';
}
private getDefaultDestinationPolicy(
forwardingMode: 'socket' | 'bridge' | 'hybrid',
useHostIp = false,
): plugins.smartvpn.IDestinationPolicy {
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
return { default: 'allow' };
}
return { default: 'forceTarget', target: '127.0.0.1' };
}
private getServerDestinationPolicy(
forwardingMode: 'socket' | 'bridge' | 'hybrid',
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
): plugins.smartvpn.IDestinationPolicy {
return this.config.destinationPolicy ?? fallbackPolicy;
}
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
if (this.config.destinationPolicy) {
return { ...this.config.destinationPolicy };
}
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
}
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
const merged = new Set<string>();
for (const list of lists) {
for (const entry of list || []) {
merged.add(entry);
}
}
return [...merged];
}
private normalizeClientRoutingSettings(
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
): void {
client.useHostIp = client.useHostIp === true;
if (!client.useHostIp) {
client.useDhcp = false;
client.staticIp = undefined;
client.forceVlan = false;
client.vlanId = undefined;
return;
}
client.useDhcp = client.useDhcp === true;
if (client.useDhcp) {
client.staticIp = undefined;
}
client.forceVlan = client.forceVlan === true;
if (!client.forceVlan) {
client.vlanId = undefined;
}
}
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
return {
description: client.description,
security: this.buildClientSecurity(client),
useHostIp: client.useHostIp,
useDhcp: client.useDhcp,
staticIp: client.staticIp,
forceVlan: client.forceVlan,
vlanId: client.vlanId,
};
}
private async restartWithForwardingMode(
forwardingMode: 'socket' | 'bridge' | 'hybrid',
reason: string,
): Promise<void> {
logger.log('info', `VPN: Restarting server in ${forwardingMode} mode ${reason}`);
this.forwardingModeOverride = forwardingMode;
await this.stop();
await this.start();
}
private async ensureForwardingModeForNextClient(useHostIp: boolean): Promise<void> {
if (!this.vpnServer) return;
const desiredForwardingMode = this.getDesiredForwardingMode(this.hasHostIpClients(useHostIp));
if (desiredForwardingMode === this.getResolvedForwardingMode()) {
return;
}
await this.restartWithForwardingMode(desiredForwardingMode, 'to support a host-IP client');
}
private async reconcileForwardingMode(): Promise<boolean> {
if (!this.vpnServer) {
return false;
}
const desiredForwardingMode = this.getDesiredForwardingMode();
const currentForwardingMode = this.getResolvedForwardingMode();
if (desiredForwardingMode === currentForwardingMode) {
return false;
}
const reason = desiredForwardingMode === 'socket'
? 'because no host-IP clients remain'
: 'to support host-IP clients';
await this.restartWithForwardingMode(desiredForwardingMode, reason);
return true;
}
private async persistClient(client: VpnClientDoc): Promise<void> {
await client.save();
}
@@ -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);
}
// =====================
+6
View File
@@ -9,12 +9,14 @@ export class RemoteIngress {
public name: string;
public secret: string;
public listenPorts: number[];
public listenPortsUdp?: number[];
public enabled: boolean;
public autoDerivePorts: boolean;
public tags?: string[];
public createdAt: number;
public updatedAt: number;
public effectiveListenPorts?: number[];
public effectiveListenPortsUdp?: number[];
public manualPorts?: number[];
public derivedPorts?: number[];
@@ -24,12 +26,14 @@ export class RemoteIngress {
this.name = data.name;
this.secret = data.secret;
this.listenPorts = data.listenPorts;
this.listenPortsUdp = data.listenPortsUdp;
this.enabled = data.enabled;
this.autoDerivePorts = data.autoDerivePorts;
this.tags = data.tags;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this.effectiveListenPorts = data.effectiveListenPorts;
this.effectiveListenPortsUdp = data.effectiveListenPortsUdp;
this.manualPorts = data.manualPorts;
this.derivedPorts = data.derivedPorts;
}
@@ -52,11 +56,13 @@ export class RemoteIngress {
const edge = response.edge;
this.name = edge.name;
this.listenPorts = edge.listenPorts;
this.listenPortsUdp = edge.listenPortsUdp;
this.enabled = edge.enabled;
this.autoDerivePorts = edge.autoDerivePorts;
this.tags = edge.tags;
this.updatedAt = edge.updatedAt;
this.effectiveListenPorts = edge.effectiveListenPorts;
this.effectiveListenPortsUdp = edge.effectiveListenPortsUdp;
this.manualPorts = edge.manualPorts;
this.derivedPorts = edge.derivedPorts;
}
+57
View File
@@ -0,0 +1,57 @@
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 getGatewayClientContext(): Promise<interfaces.data.IGatewayClientContext> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetGatewayClientContext>(
'getGatewayClientContext',
this.clientRef.buildRequestPayload() as any,
);
return response.context;
}
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';
+89 -207
View File
@@ -1,20 +1,18 @@
# @serve.zone/dcrouter-apiclient
A typed, object-oriented API client for DcRouter with a fluent builder pattern. 🔧
Programmatically manage your DcRouter instance — routes, certificates, API tokens, remote ingress edges, RADIUS, email operations, and more — all with full TypeScript type safety and an intuitive OO interface.
`@serve.zone/dcrouter-apiclient` is the object-oriented TypeScript client for the dcrouter OpsServer API. It wraps `/typedrequest` calls in managers, builders, and resource classes for routes, certificates, API tokens, remote ingress, email, stats, config, logs, RADIUS, and WorkHoster integrations.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Installation
## Install
```bash
pnpm add @serve.zone/dcrouter-apiclient
```
Or import directly from the main package:
The same client is also exposed as a subpath of the main package:
```typescript
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
@@ -23,239 +21,123 @@ import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
## Quick Start
```typescript
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
import { DcRouterApiClient } from '@serve.zone/dcrouter-apiclient';
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
// Authenticate
await client.login('admin', 'password');
// List routes
const { routes, warnings } = await client.routes.list();
console.log(`${routes.length} routes, ${warnings.length} warnings`);
// Check health
const { health } = await client.stats.getHealth();
console.log(`Healthy: ${health.healthy}`);
```
## Usage
### 🔐 Authentication
```typescript
// Login with credentials — identity is stored and auto-injected into all subsequent requests
const identity = await client.login('admin', 'password');
// Verify current session
const { valid } = await client.verifyIdentity();
// Logout
await client.logout();
// Or use an API token for programmatic access (route management only)
const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
apiToken: 'dcr_your_token_here',
});
await client.login('admin@example.com', 'strong-password');
const { routes, warnings } = await client.routes.list();
console.log(routes.length, warnings.length);
const route = await client.routes.build()
.setName('api-gateway')
.setMatch({ ports: 443, domains: ['api.example.com'] })
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
.save();
await route.toggle(true);
```
## Authentication
The client supports persisted-admin session login and API-token authentication. Initial admin creation is a bootstrap flow exposed by the Ops dashboard and raw TypedRequest contracts; after a persisted admin exists, use that account with `login()`.
```typescript
const sessionClient = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
});
await sessionClient.login('admin@example.com', 'strong-password');
const tokenClient = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
apiToken: 'dcr_token_value',
});
```
### 🌐 Routes — OO Resources + Builder
`baseUrl` is normalized by removing trailing slashes. Requests are sent to `${baseUrl}/typedrequest`. `buildRequestPayload()` injects the current identity and optional API token for manager methods.
Routes are returned as `Route` instances with methods for update, delete, toggle, and overrides:
## Manager Map
| Manager | Purpose |
| --- | --- |
| `client.routes` | List merged routes, build API routes, update/delete API routes, and toggle routes. |
| `client.certificates` | Inspect certificate summaries and trigger certificate operations. |
| `client.apiTokens` | Create, list, toggle, roll, and revoke API tokens. |
| `client.remoteIngress` | Manage edge registrations, statuses, ports, tags, and connection tokens. |
| `client.emails` | Inspect received/cached email items and trigger resend flows. |
| `client.workHosters` | Manage WorkHoster-facing route/application integration calls. |
| `client.stats` | Read health, counters, summaries, and runtime status. |
| `client.config` | Read the current configuration view. |
| `client.logs` | Read recent log information. |
| `client.radius` | Manage RADIUS clients, VLAN mappings, and accounting sessions. |
## Route Builder
```typescript
// List all routes (hardcoded + programmatic)
const { routes, warnings } = await client.routes.list();
// Inspect a route
const route = routes[0];
console.log(route.name, route.source, route.enabled);
// Modify a programmatic route
await route.update({ name: 'renamed-route' });
await route.toggle(false);
await route.delete();
// Override a hardcoded route (disable it)
const hardcodedRoute = routes.find(r => r.source === 'hardcoded');
await hardcodedRoute.setOverride(false);
await hardcodedRoute.removeOverride();
```
**Builder pattern** for creating new routes:
```typescript
const newRoute = await client.routes.build()
.setName('api-gateway')
.setMatch({ ports: 443, domains: ['api.example.com'] })
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
.setTls({ mode: 'terminate', certificate: 'auto' })
const route = await client.routes.build()
.setName('internal-app')
.setMatch({
ports: 443,
domains: ['internal.example.com'],
})
.setAction({
type: 'forward',
targets: [{ host: '127.0.0.1', port: 3000 }],
})
.setEnabled(true)
.save();
// Or use quick creation
const route = await client.routes.create(routeConfig);
```
### 🔑 API Tokens
```typescript
// List existing tokens
const tokens = await client.apiTokens.list();
// Create with builder
const token = await client.apiTokens.build()
.setName('ci-pipeline')
.setScopes(['routes:read', 'routes:write'])
.addScope('config:read')
.setExpiresInDays(90)
.save();
console.log(token.tokenValue); // Only available at creation time!
// Manage tokens
await token.toggle(false); // Disable
const newValue = await token.roll(); // Regenerate secret
await token.revoke(); // Delete
```
### 🔐 Certificates
```typescript
const { certificates, summary } = await client.certificates.list();
console.log(`${summary.valid} valid, ${summary.expiring} expiring, ${summary.failed} failed`);
// Operate on individual certificates
const cert = certificates[0];
await cert.reprovision();
const exported = await cert.export();
await cert.delete();
// Import a certificate
await client.certificates.import({
id: 'cert-id',
domainName: 'example.com',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 3600 * 1000,
privateKey: '...',
publicKey: '...',
csr: '...',
await route.update({
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 3001 }],
},
});
```
### 🌍 Remote Ingress
System routes from `config`, `email`, and `dns` origins are designed to be toggled, not edited. Full create/update/delete behavior is for routes with origin `api`.
## API Tokens and Remote Ingress
```typescript
// List edges and their statuses
const edges = await client.remoteIngress.list();
const statuses = await client.remoteIngress.getStatuses();
const token = await client.apiTokens.build()
.setName('automation')
.setScopes(['routes:read', 'routes:write'])
.setExpiresInDays(30)
.save();
// Create with builder
const edge = await client.remoteIngress.build()
.setName('edge-nyc-01')
.setName('edge-eu-1')
.setListenPorts([80, 443])
.setAutoDerivePorts(true)
.setTags(['us-east'])
.setTags(['production', 'eu'])
.save();
// Manage an edge
await edge.update({ name: 'edge-nyc-02' });
const newSecret = await edge.regenerateSecret();
const token = await edge.getConnectionToken();
await edge.delete();
const connectionToken = await edge.getConnectionToken();
console.log(token.tokenValue, connectionToken);
```
### 📊 Statistics (Read-Only)
## What This Package Is Not
```typescript
const serverStats = await client.stats.getServer({ timeRange: '24h', includeHistory: true });
const emailStats = await client.stats.getEmail({ domain: 'example.com' });
const dnsStats = await client.stats.getDns();
const security = await client.stats.getSecurity({ includeDetails: true });
const connections = await client.stats.getConnections({ protocol: 'https' });
const queues = await client.stats.getQueues();
const health = await client.stats.getHealth(true);
const network = await client.stats.getNetwork();
const combined = await client.stats.getCombined({ server: true, email: true });
```
- It does not start dcrouter.
- It does not serve or bundle the Ops dashboard.
- It does not replace `@serve.zone/dcrouter-interfaces` when you want raw TypedRequest contracts.
### ⚙️ Configuration & Logs
Use `@serve.zone/dcrouter` for the server runtime and `@serve.zone/dcrouter-interfaces` for shared request/data types.
```typescript
// Read-only configuration
const config = await client.config.get();
const emailSection = await client.config.get('email');
## Development
// Logs
const { logs, total, hasMore } = await client.logs.getRecent({
level: 'error',
category: 'smtp',
limit: 50,
});
```
This folder is published from the dcrouter monorepo via `tspublish.json` with order `5`.
### 📧 Email Operations
Useful source entry points:
```typescript
const emails = await client.emails.list();
const email = emails[0];
const detail = await email.getDetail();
await email.resend();
// Or use the manager directly
const detail2 = await client.emails.getDetail('email-id');
await client.emails.resend('email-id');
```
### 📡 RADIUS
```typescript
// Client management
const clients = await client.radius.clients.list();
await client.radius.clients.set({
name: 'switch-1',
ipRange: '192.168.1.0/24',
secret: 'shared-secret',
enabled: true,
});
await client.radius.clients.remove('switch-1');
// VLAN management
const { mappings, config: vlanConfig } = await client.radius.vlans.list();
await client.radius.vlans.set({ mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true });
const result = await client.radius.vlans.testAssignment('aa:bb:cc:dd:ee:ff');
await client.radius.vlans.updateConfig({ defaultVlan: 200 });
// Sessions
const { sessions } = await client.radius.sessions.list({ vlanId: 10 });
await client.radius.sessions.disconnect('session-id', 'Admin disconnect');
// Statistics & Accounting
const stats = await client.radius.getStatistics();
const summary = await client.radius.getAccountingSummary(startTime, endTime);
```
## API Surface
| Manager | Methods |
|---------|---------|
| `client.login()` / `logout()` / `verifyIdentity()` | Authentication |
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getRateLimits()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
| `client.config` | `get(section?)` |
| `client.logs` | `getRecent()`, `getStream()` |
| `client.emails` | `list()`, `getDetail()`, `resend()` → Email: `getDetail()`, `resend()` |
| `client.radius` | `.clients.list/set/remove()`, `.vlans.list/set/remove/updateConfig/testAssignment()`, `.sessions.list/disconnect()`, `getStatistics()`, `getAccountingSummary()` |
## Architecture
The client uses HTTP-based [TypedRequest](https://code.foss.global/api.global/typedrequest) for transport. All requests are sent as POST to `{baseUrl}/typedrequest`. Authentication (JWT identity and/or API token) is automatically injected into every request payload via `buildRequestPayload()`.
Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) hold a reference to the client and provide instance methods that fire the appropriate TypedRequest operations. Builder classes (`RouteBuilder`, `ApiTokenBuilder`, `RemoteIngressBuilder`) use fluent chaining and a terminal `.save()` method.
- `index.ts` exports the public client surface.
- `classes.dcrouterapiclient.ts` owns authentication and request dispatch.
- `classes.route.ts` owns route resources and builders.
- `classes.remoteingress.ts`, `classes.apitoken.ts`, `classes.radius.ts`, and the other manager files wrap focused API domains.
## License and Legal Information
+3 -1
View File
@@ -6,6 +6,8 @@ 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';
export * from './email-domain.js';
export * from './security-policy.js';
+58
View File
@@ -36,6 +36,64 @@ export interface IRemoteIngressStatus {
activeTunnels: number;
lastHeartbeat: number | null;
connectedAt: number | null;
transportMode?: 'tcpTls' | 'quic' | 'quicWithFallback';
fallbackUsed?: boolean;
performance?: IRemoteIngressPerformanceEffective;
flowControl?: IRemoteIngressFlowControlStatus;
queues?: IRemoteIngressQueueStatus;
traffic?: IRemoteIngressTrafficStatus;
udp?: IRemoteIngressUdpStatus;
}
export type TRemoteIngressPerformanceProfile = 'balanced' | 'throughput' | 'highConcurrency';
export interface IRemoteIngressPerformanceConfig {
profile?: TRemoteIngressPerformanceProfile;
maxStreamsPerEdge?: number;
totalWindowBudgetBytes?: number;
minStreamWindowBytes?: number;
maxStreamWindowBytes?: number;
sustainedStreamWindowBytes?: number;
quicDatagramReceiveBufferBytes?: number;
}
export interface IRemoteIngressPerformanceEffective {
profile: TRemoteIngressPerformanceProfile;
maxStreamsPerEdge: number;
totalWindowBudgetBytes: number;
minStreamWindowBytes: number;
maxStreamWindowBytes: number;
sustainedStreamWindowBytes: number;
quicDatagramReceiveBufferBytes: number;
}
export interface IRemoteIngressFlowControlStatus {
applies: boolean;
currentWindowBytes: number;
minWindowBytes: number;
maxWindowBytes: number;
totalWindowBudgetBytes: number;
estimatedInFlightBytes: number;
stalledStreams: number;
}
export interface IRemoteIngressQueueStatus {
ctrlQueueDepth: number;
dataQueueDepth: number;
sustainedQueueDepth: number;
}
export interface IRemoteIngressTrafficStatus {
bytesIn: number;
bytesOut: number;
streamsOpenedTotal: number;
streamsClosedTotal: number;
rejectedStreams: number;
}
export interface IRemoteIngressUdpStatus {
activeSessions: number;
droppedDatagrams: number;
}
/**
+87 -11
View File
@@ -8,17 +8,77 @@ export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
// Route Management Data Types
// ============================================================================
export type TApiTokenScope =
| 'routes:read' | 'routes:write'
| 'config:read'
| 'tokens:read' | 'tokens:manage'
| 'source-profiles:read' | 'source-profiles:write'
| 'target-profiles:read' | 'target-profiles:write'
| 'targets:read' | 'targets:write'
| 'dns-providers:read' | 'dns-providers:write'
| 'domains:read' | 'domains:write'
| 'dns-records:read' | 'dns-records:write'
| 'acme-config:read' | 'acme-config:write';
export const apiTokenScopes = [
'*',
'routes:read',
'routes:write',
'config:read',
'stats:read',
'logs:read',
'security:read',
'security:write',
'emails:read',
'emails:write',
'certificates:read',
'certificates:write',
'tokens:read',
'tokens:manage',
'users:read',
'users:manage',
'source-profiles:read',
'source-profiles:write',
'target-profiles:read',
'target-profiles:write',
'targets:read',
'targets:write',
'dns-providers:read',
'dns-providers:write',
'domains:read',
'domains:write',
'dns-records:read',
'dns-records:write',
'acme-config:read',
'acme-config:write',
'email-domains:read',
'email-domains:write',
'remote-ingress:read',
'remote-ingress:write',
'vpn:read',
'vpn:write',
'radius:read',
'radius:write',
'gateway-clients:read',
'gateway-clients:write',
'workhosters:read',
'workhosters:write',
] as const;
export type TApiTokenScope = typeof apiTokenScopes[number];
export type TGatewayClientType = 'onebox' | 'cloudly' | 'custom';
/** @deprecated Use TGatewayClientType. */
export type TWorkHosterType = TGatewayClientType;
export interface IApiTokenPolicy {
role: 'admin' | 'gatewayClient' | 'operator';
scopes?: TApiTokenScope[];
gatewayClient?: {
type: TGatewayClientType;
id: string;
};
hostnamePatterns?: string[];
allowedRouteTargets?: Array<{
host: string;
ports: number[];
}>;
capabilities?: {
readDomains?: boolean;
readDnsRecords?: boolean;
syncRoutes?: boolean;
syncDnsRecords?: boolean;
requestCertificates?: boolean;
};
}
// ============================================================================
// Source Profile Types (source-side: who can access)
@@ -80,6 +140,18 @@ export interface IRouteMetadata {
networkTargetName?: string;
/** Timestamp of last reference resolution. */
lastResolvedAt?: number;
/** External route ownership, used by WorkHoster reconciliation. */
ownerType?: 'gatewayClient' | 'workhoster' | 'operator' | 'system';
gatewayClientType?: TGatewayClientType;
gatewayClientId?: string;
gatewayClientAppId?: string;
/** @deprecated Use gatewayClientType. */
workHosterType?: TGatewayClientType;
/** @deprecated Use gatewayClientId. */
workHosterId?: string;
/** @deprecated Use gatewayClientAppId. */
workAppId?: string;
externalKey?: string;
}
/**
@@ -90,6 +162,7 @@ export interface IMergedRoute {
id: string;
enabled: boolean;
origin: 'config' | 'email' | 'dns' | 'api';
systemKey?: string;
createdAt?: number;
updatedAt?: number;
metadata?: IRouteMetadata;
@@ -111,6 +184,7 @@ export interface IApiTokenInfo {
id: string;
name: string;
scopes: TApiTokenScope[];
policy?: IApiTokenPolicy;
createdAt: number;
expiresAt: number | null;
lastUsedAt: number | null;
@@ -132,6 +206,7 @@ export interface IRoute {
updatedAt: number;
createdBy: string;
origin: 'config' | 'email' | 'dns' | 'api';
systemKey?: string;
metadata?: IRouteMetadata;
}
@@ -143,6 +218,7 @@ export interface IStoredApiToken {
name: string;
tokenHash: string;
scopes: TApiTokenScope[];
policy?: IApiTokenPolicy;
createdAt: number;
expiresAt: number | null;
lastUsedAt: number | null;

Some files were not shown because too many files have changed in this diff Show More