Compare commits

...

103 Commits

Author SHA1 Message Date
jkunz e6b3625256 v14.0.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m42s
2026-06-05 03:28:38 +00:00
jkunz 103680a3a0 fix(proxy-protocol): apply inbound PROXY protocol policies per listener 2026-06-05 03:17:37 +00:00
jkunz ba67e0d208 v14.0.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m13s
2026-06-04 16:01:33 +00:00
jkunz e86fe0df7a BREAKING CHANGE(config): remove legacy config seeding and route reprovisioning 2026-06-04 15:51:09 +00:00
jkunz 71ee2133e4 v13.45.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m20s
2026-06-04 13:39:39 +00:00
jkunz 6c8073b91a feat(network-routes): add route source policy editor 2026-06-04 13:36:02 +00:00
jkunz 17bb63f129 fix(email): relay server-first SMTP banners for generated email routes 2026-06-04 12:06:09 +00:00
jkunz 2ec647cd6c v13.44.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m24s
2026-06-04 10:18:40 +00:00
jkunz 01267cfeb5 fix(db): use smartdata cached document support 2026-06-04 10:13:57 +00:00
jkunz eef053bd66 v13.44.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 18m44s
2026-06-04 03:59:48 +00:00
jkunz ccb4dea91e test(migrations): use example IP in fixtures 2026-06-04 03:54:32 +00:00
jkunz b0b480873f feat(settings): add DB-backed email and RemoteIngress hub settings 2026-06-04 03:46:31 +00:00
jkunz 496dba94b1 v13.43.5
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m46s
2026-06-03 16:10:17 +00:00
jkunz 69dbc29662 fix(deps): bump @serve.zone/catalog to ^2.12.8 2026-06-03 16:06:29 +00:00
jkunz 3bd6d2f2de v13.43.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m50s
2026-06-03 14:28:26 +00:00
jkunz 2c8cc93952 fix(remoteingress): track tunnel streams using summary events 2026-06-03 14:24:43 +00:00
jkunz 3f50518b80 v13.43.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m27s
2026-06-03 11:57:22 +00:00
jkunz 15ca5d137c chore(changelog): consolidate smartproxy dependency entry 2026-06-03 11:54:07 +00:00
jkunz 16a4b04dfb fix(deps): bump @push.rocks/smartproxy to ^27.12.6 2026-06-03 11:53:02 +00:00
jkunz 03b494018a fix(deps): bump @push.rocks/smartproxy to ^27.12.6 2026-06-03 11:43:16 +00:00
jkunz 9c08384df0 v13.43.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m22s
2026-06-03 09:32:11 +00:00
jkunz 9286f56316 fix(route-management): use canonical source bindings 2026-06-03 06:46:38 +00:00
jkunz 1c4caf2b85 v13.43.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m57s
2026-06-03 04:20:56 +00:00
jkunz 4a09b273df fix(dockerignore): ignore generated artifacts and caches in Docker build context 2026-06-03 04:17:02 +00:00
jkunz 4ceb46b509 v13.43.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m6s
2026-06-03 03:29:58 +00:00
jkunz 0aa1cde5eb feat(http-redirects): add derived HTTP-to-HTTPS redirects 2026-06-03 03:24:55 +00:00
jkunz 584782dcb7 v13.42.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m58s
2026-06-02 18:59:20 +00:00
jkunz 810ecf46f8 fix(deps): update Deno import dependencies 2026-06-02 17:38:51 +00:00
jkunz 6d5d23a691 fix(source-policy-compiler): normalize source policy route priorities to stable integers 2026-06-02 17:25:18 +00:00
jkunz c6617c79f5 v13.42.3
Release / build-and-release (push) Successful in 6m49s
Docker (tags) / release (push) Failing after 1s
2026-06-02 15:40:09 +00:00
jkunz 135432260d fix(deps): update dependency versions 2026-06-02 15:40:07 +00:00
jkunz b55d2ac61d v13.42.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m1s
2026-06-02 14:11:18 +00:00
jkunz c88e8e1758 fix(dev-deps): bump @git.zone/tsdocker to ^2.4.1 2026-06-02 14:10:49 +00:00
jkunz 6ee716e4ef v13.42.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m6s
2026-06-02 12:48:16 +00:00
jkunz 1d4ed9af2c fix(deps): bump @serve.zone/remoteingress to ^4.22.5 2026-06-02 12:47:53 +00:00
jkunz d2331fdcbe v13.42.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m27s
2026-06-02 00:29:38 +00:00
jkunz 0e7765c740 feat(source-policy): add ordered route source policies with Gitea preset support 2026-06-02 00:29:13 +00:00
jkunz 1a381df937 v13.41.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m57s
2026-06-01 14:49:38 +00:00
jkunz 38e2f3cee1 fix(deps): update smartproxy and remoteingress 2026-06-01 14:38:34 +00:00
jkunz 4a47460bf1 v13.41.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m50s
2026-05-31 21:06:24 +00:00
jkunz 3679cba3a4 fix(smartacme): prevent SmartAcme startup from blocking router startup 2026-05-31 21:05:34 +00:00
jkunz 3dc0371f7e v13.41.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m5s
2026-05-31 19:42:51 +00:00
jkunz b212662764 feat(remoteingress): add RemoteIngress hub settings management 2026-05-31 19:42:17 +00:00
jkunz 776c65a18c v13.40.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m44s
2026-05-31 16:23:56 +00:00
jkunz 5f6ec63770 fix(deps): bump smartproxy and remoteingress dependencies 2026-05-31 16:23:48 +00:00
jkunz 1b4cc0567f v13.40.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m0s
2026-05-31 15:26:43 +00:00
jkunz 22de50b544 fix(routes): ensure source profiles fully own route security 2026-05-31 15:26:18 +00:00
jkunz 2e3bead40c v13.40.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 19m10s
2026-05-31 11:50:08 +00:00
jkunz 85065b05c8 fix(deps): update smartproxy, remoteingress, and tsdeno dependencies 2026-05-31 11:49:25 +00:00
jkunz 7f7a26fb38 v13.40.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 8m31s
2026-05-30 19:57:32 +00:00
jkunz a089b681c4 feat(monitoring-opsserver-radius): use active connection snapshots for proxy metrics and RADIUS network secrets 2026-05-30 19:57:09 +00:00
jkunz 3e71301bf5 v13.39.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m54s
2026-05-30 18:09:42 +00:00
jkunz 58cc8c0753 feat(remoteingress,radius): add remote ingress performance overrides and update RADIUS integration 2026-05-30 18:09:18 +00:00
jkunz e279814803 v13.38.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m11s
2026-05-30 15:05:32 +00:00
jkunz 6bee2eb172 fix(deps): bump @serve.zone/remoteingress to ^4.22.1 2026-05-30 15:05:16 +00:00
jkunz db8ea99e88 v13.38.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m19s
2026-05-30 13:19:15 +00:00
jkunz 98ccf82af0 fix(deps): update @serve.zone/remoteingress to ^4.22.0 2026-05-30 13:18:48 +00:00
jkunz 0f99525612 v13.38.2
Docker (tags) / release (push) Failing after 16m7s
Release / build-and-release (push) Failing after 14m45s
2026-05-30 11:40:28 +00:00
jkunz 8e707d9c4d fix(deps): bump @serve.zone/remoteingress to ^4.21.1 2026-05-30 11:40:00 +00:00
jkunz 418c825b01 v13.38.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 8m58s
2026-05-30 10:35:31 +00:00
jkunz 75f29af27f fix(deps): update @serve.zone/remoteingress to ^4.21.0 2026-05-30 10:35:02 +00:00
jkunz 4467fe629a fix(deps): bump @serve.zone/remoteingress to ^4.21.0 2026-05-30 10:31:37 +00:00
jkunz 1912feffe5 v13.38.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m45s
2026-05-29 17:57:08 +00:00
jkunz 9077b3dad6 feat(dns): support explicit DNS bind interface configuration 2026-05-29 17:56:33 +00:00
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
149 changed files with 14563 additions and 2809 deletions
+8
View File
@@ -1,7 +1,15 @@
node_modules/
.nogit/
.git/
.cache/
.rpt2_cache
.yarn/
.playwright-mcp/
.vscode/
coverage/
dist/
dist_*/
pages/
public/
test/
test_watch/
+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
+53 -9
View File
@@ -23,11 +23,36 @@
"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": "code.foss.global",
@@ -60,18 +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"
}
}
}
},
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registries": [
"code.foss.global"
],
"registryRepoMap": {
"code.foss.global": "serve.zone/dcrouter"
},
"platforms": ["linux/amd64", "linux/arm64"]
}
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"@ship.zone/szci": {}
}
+4
View File
@@ -0,0 +1,4 @@
process.env.CLI_CALL = 'true';
const cliTool = await import('../dist_ts/index.js');
await cliTool.runCli();
+458 -1
View File
@@ -1,5 +1,462 @@
# Changelog
## Pending
## 2026-06-05 - 14.0.1
### Fixes
- apply inbound PROXY protocol policies per listener (proxy-protocol)
- Apply inbound PROXY protocol policies across prepared and runtime routes that share the same listener.
- Require PROXY protocol for remote ingress SMTP and submission ports while using optional mode for other remote ingress and VPN listeners.
- Trust localhost for remote ingress and VPN forwarding without globally enabling PROXY protocol.
- Bump @push.rocks/smartproxy to ^27.12.8.
## 2026-06-04 - 14.0.0
### Breaking Changes
- remove legacy config seeding and route-based certificate reprovisioning (config)
- Make ACME configuration DB-backed only and report DB-backed ACME state in the OpsServer config response.
- Stop seeding DNS domains and records from constructor config at runtime.
- Remove the route-name certificate reprovision typed request; domain-based reprovisioning remains available.
- Remove legacy string email-domain normalization from runtime email startup.
### Fixes
- bump @push.rocks/smartproxy to ^27.12.7 (deps)
- Consumes the upstream SmartProxy socket-handler relay fix for server-first SMTP banners.
- Updates the lockfile to resolve @push.rocks/smartproxy 27.12.7.
- use exact SmartData collection names in DNS migrations (migrations)
- Updates DNS source rename migrations to use `DomainDoc` and `DnsRecordDoc` collection names.
- Adds migration coverage for exact SmartData collection names.
## 2026-06-04 - 13.45.0
### Fixes
- relay server-first SMTP banners for generated email routes (email)
- Convert generated plaintext email forward routes to runtime socket handlers for SmartProxy bootstrap.
- Hydrate DB-backed generated email routes to the same runtime handlers when their email system keys match.
- Add bidirectional socket proxy cleanup and tests for route hydration and SMTP banner relay.
### Features
- add route source policy editor (network-routes)
- Replace fixed source binding dropdown rows with the catalog route source policy input in route create and edit dialogs.
- Add source profile normalization, path class options, Gitea source policy presets, and validation for route source policies.
- Bump catalog UI dependencies and update pnpm built dependency configuration.
## 2026-06-04 - 13.44.1
### Fixes
- use smartdata cached document support (db)
- Migrate cached email and IP reputation documents to SmartdataCachedDocument and shared smartdata TTL values.
- Remove the local cached document base class and TTL export.
- Bump @push.rocks/smartdata to ^7.2.0.
## 2026-06-04 - 13.44.0
### Features
- add DB-backed email and RemoteIngress hub settings (settings)
- Add persisted email server settings with ops API handlers and web UI controls.
- Extend RemoteIngress hub settings to manage enabled state, tunnel port, hub domain, and performance from the database.
- Backfill email and RemoteIngress singleton settings from legacy bootstrap configuration during migrations.
- Serialize SmartProxy, RemoteIngress, and email lifecycle updates to avoid overlapping runtime reconfiguration.
## 2026-06-03 - 13.43.5
### Fixes
- bump @serve.zone/catalog to ^2.12.8 (deps)
- Updated @serve.zone/catalog from ^2.12.7 to ^2.12.8.
## 2026-06-03 - 13.43.4
### Fixes
- track tunnel streams using summary events (remoteingress)
- Enable summary stream event mode for the RemoteIngress hub.
- Synchronize active tunnel counts and stream totals from stream summary events.
- Bump @serve.zone/remoteingress to ^4.23.0.
- Remove obsolete Deno import map entries.
## 2026-06-03 - 13.43.3
### Fixes
- bump @push.rocks/smartproxy to ^27.12.6 (deps)
- Updates package and Deno import dependencies from @push.rocks/smartproxy ^27.12.4 to ^27.12.6.
## 2026-06-03 - 13.43.2
### Fixes
- enforce canonical source bindings for route access (route-management)
- Convert route access metadata to ordered `metadata.sourceBindings[]` and remove active runtime use of legacy source policy/source profile fields.
- Fail closed for managed gateway/workhoster routes without source bindings and add terminal deny fallbacks for private-only bindings.
- Add migration coverage, Ops route UI updates, and documentation for the canonical source binding model.
## 2026-06-03 - 13.43.1
### Fixes
- ignore generated artifacts and caches in Docker build context (dockerignore)
- Exclude cache directories, coverage reports, distribution outputs, and generated static assets from Docker contexts.
## 2026-06-03 - 13.43.0
### Features
- add derived HTTP-to-HTTPS redirects (http-redirects)
- Generate 301 runtime redirect routes from eligible HTTPS routes while detecting existing HTTP route coverage or conflicts
- Expose derived redirect metadata through the getHttpRedirects typed request API
- Add an Ops Redirects network view with redirect status metrics and table details
- Add tests for redirect derivation, conflict handling, and preserving request host/path
## 2026-06-02 - 13.42.4
### Fixes
- normalize source policy route priorities to stable integers (source-policy-compiler)
- Assign integer priorities to compiled source policy route variants while preserving relative priority order.
- Keep path-specific source policy variants ranked above fallback variants.
- update Deno import dependencies (deps)
- Bumped Deno import map versions for API, identity, push.rocks, serve.zone, and lru-cache dependencies.
## 2026-06-02 - 13.42.3
### Fixes
- update dependency versions (deps)
- Bumped runtime dependencies including @serve.zone/interfaces to ^6.2.1, @serve.zone/catalog to ^2.12.7, and lru-cache to ^11.5.1.
- Updated @git.zone/tsdocker dev dependency to ^2.4.2.
## 2026-06-02 - 13.42.2
### Fixes
- bump @git.zone/tsdocker to ^2.4.1 (dev-deps)
- Updated @git.zone/tsdocker from ^2.4.0 to ^2.4.1.
## 2026-06-02 - 13.42.1
### Fixes
- bump @serve.zone/remoteingress to ^4.22.5 (deps)
- Updates @serve.zone/remoteingress from ^4.22.4 to ^4.22.5.
## 2026-06-02 - 13.42.0
### Features
- add ordered route source policies with Gitea preset support (source-policy)
- Compile metadata.sourcePolicy bindings into SmartProxy route variants with ordered source matching, path-class overrides, and terminal 429 rate/connection limit handling
- Add shared source-policy interfaces, Gitea path-class patterns, validation limits, and resolver support for policy-backed profile usage and display names
- Add Ops UI controls for manual and Gitea source-policy presets plus rate-limit editing for source profiles
- Seed TRUSTED NETWORKS, AI CRAWLERS, and PUBLIC default profiles through defaults and the 13.42.0 migration
- Bump smartproxy to ^27.12.4 and add coverage for source-policy compilation, rate-limit behavior, migrations, and port-safe server tests
## 2026-06-01 - 13.41.2
### Fixes
- update SmartProxy and RemoteIngress dependencies (deps)
- Bump SmartProxy to 27.12.3 for the published half-close regression coverage.
- Bump RemoteIngress to 4.22.4 for the half-close/reset and UDP startup lifecycle fixes.
- Align npm and Deno import metadata for both runtime dependencies.
## 2026-05-31 - 13.41.1
### Fixes
- prevent SmartAcme startup from blocking router startup (smartacme)
- Start SmartAcme in the background with bounded exponential retry handling
- Re-trigger certificate provisioning after SmartAcme becomes ready
- Cancel stale retry timers and clean up SmartAcme instances during shutdown or config updates
## 2026-05-31 - 13.41.0
### Features
- add RemoteIngress hub settings management (remoteingress)
- Persist hub-level RemoteIngress performance settings with validation and seed defaults from config
- Add typed read/update handlers and web UI controls for hub performance settings
- Restart the tunnel hub after hub setting updates so new performance defaults take effect
- Serialize RemoteIngress lifecycle tasks, edge mutations, route syncs, and stop/start operations to avoid hub race conditions
## 2026-05-31 - 13.40.3
### Fixes
- bump smartproxy and remoteingress dependencies (deps)
- Bumped @push.rocks/smartproxy from ^27.12.1 to ^27.12.2
- Bumped @serve.zone/remoteingress from ^4.22.2 to ^4.22.3
- Updated dependency versions in both package.json and deno.json
## 2026-05-31 - 13.40.2
### Fixes
- ensure source profiles fully own route security (routes)
- Resolve profile-backed routes by cloning source profile security instead of merging inline route overrides
- Clear stale route security when a source profile reference is removed without explicit replacement security
- Add a migration to rematerialize persisted profile-backed route security
## 2026-05-31 - 13.40.1
### Fixes
- update smartproxy, remoteingress, and tsdeno dependencies (deps)
- Bump @push.rocks/smartproxy to ^27.12.1 in Deno imports
- Bump @serve.zone/remoteingress to ^4.22.2 in package and Deno configuration
- Bump @git.zone/tsdeno to ^1.5.0
## 2026-05-30 - 13.40.0
### Features
- use active connection snapshots for proxy metrics and RADIUS network secrets (monitoring-opsserver-radius)
- Add cached SmartProxy active connection snapshots for connection info and network statistics.
- Report ops security active connections from per-connection snapshots with protocol, state, and byte counters.
- Configure RADIUS clients through smartradius network secrets, including CIDR ranges, and forward additional RADIUS attributes.
- Bump smartproxy to ^27.12.1 and smartradius to ^1.3.0.
## 2026-05-30 - 13.39.0
### Features
- add remote ingress performance overrides and update RADIUS integration (remoteingress,radius)
- Persist and propagate optional remote ingress performance overrides through remote ingress create/update APIs, database documents, and hub allowed-edge sync.
- Add web UI controls and status display for per-edge maximum connection overrides.
- Extend remote ingress performance interfaces with stream payload, timeout, and server-first port settings.
- Update RADIUS server integration for smartradius 1.2 request/response handling and client secret resolution, including CIDR matching.
## 2026-05-30 - 13.38.4
### Fixes
- bump @serve.zone/remoteingress to ^4.22.1 (deps)
- Updated @serve.zone/remoteingress in package.json and deno.json.
## 2026-05-30 - 13.38.3
### Fixes
- update @serve.zone/remoteingress to ^4.22.0 (deps)
- Updated @serve.zone/remoteingress from ^4.21.1 to ^4.22.0 in package.json and deno.json.
## 2026-05-30 - 13.38.2
### Fixes
- bump @serve.zone/remoteingress to ^4.21.1 (deps)
- Updated @serve.zone/remoteingress in package.json and deno.json from ^4.21.0 to ^4.21.1.
## 2026-05-30 - 13.38.1
### Fixes
- bump @serve.zone/remoteingress to ^4.21.0 (deps)
- Updates @serve.zone/remoteingress from ^4.18.0 to ^4.21.0.
- update @serve.zone/remoteingress to ^4.21.0 (deps)
- Updates the Deno import mapping for @serve.zone/remoteingress from ^4.18.0 to ^4.21.0.
## 2026-05-29 - 13.38.0
### Features
- support explicit DNS bind interface configuration (dns)
- Add a dnsBindInterface option to override the embedded DNS UDP bind address.
- Read DCROUTER_DNS_BIND_INTERFACE from OCI container configuration and document it in CLI help.
- Add test coverage for explicit DNS bind interface handling in OCI config.
## 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
@@ -2604,4 +3061,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.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@serve.zone/dcrouter",
"version": "14.0.1",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
"dist_serve"
]
}
}
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 ""
+37 -34
View File
@@ -1,9 +1,12 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.27.1",
"version": "14.0.1",
"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",
@@ -15,60 +18,63 @@
"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.1",
"@git.zone/tsdocker": "^2.2.5",
"@git.zone/tsrun": "^2.0.3",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.3",
"@types/node": "^25.6.1"
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdeno": "^1.5.0",
"@git.zone/tsdocker": "^2.4.2",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest": "^3.3.2",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@api.global/typedserver": "^8.4.7",
"@api.global/typedsocket": "^4.1.4",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-catalog": "^3.84.0",
"@design.estate/dees-element": "^2.2.4",
"@idp.global/sdk": "^1.4.0",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdb": "^2.10.0",
"@push.rocks/smartdns": "^7.9.2",
"@push.rocks/smartdata": "^7.2.0",
"@push.rocks/smartdb": "^2.10.2",
"@push.rocks/smartdns": "^7.9.3",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.2",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.3.1",
"@push.rocks/smartmigration": "1.4.1",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.7.1",
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.10.0",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartproxy": "^27.12.8",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@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.4",
"@serve.zone/interfaces": "^5.5.0",
"@serve.zone/remoteingress": "^4.17.1",
"@serve.zone/catalog": "^2.13.0",
"@serve.zone/interfaces": "^6.2.1",
"@serve.zone/remoteingress": "^4.23.0",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.3.6",
"lru-cache": "^11.5.1",
"qrcode": "^1.5.4",
"uuid": "^14.0.0"
},
@@ -98,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"
]
+725 -704
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@design.estate/dees-catalog'
- esbuild
- mongodb-memory-server
- puppeteer
+130 -3
View File
@@ -34,6 +34,20 @@ Highlights:
## Install
Install the CLI/runtime on a Linux gateway host with the released self-extracting binary:
```bash
curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
```
The installer downloads `dcrouter-linux-x64` or `dcrouter-linux-arm64` from the latest Gitea release, installs it under `/opt/dcrouter`, and links `/usr/local/bin/dcrouter`. Use `--version vX.Y.Z` to pin a release, `--install-dir /path` to change the target directory, or `--source` to clone the tag and build the NodeNext package locally.
```bash
curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
```
Use the package as a TypeScript library:
```bash
pnpm add @serve.zone/dcrouter
```
@@ -73,10 +87,22 @@ await router.start();
After startup:
- open the dashboard at `http://localhost:3000`
- log in with the current built-in development credentials `admin` / `admin`
- complete the first-admin bootstrap flow if no persisted admin account exists yet
- send proxied traffic to `http://localhost:18080`
- stop gracefully with `await router.stop()`
## Initial Admin Bootstrap
When DB-backed persistence is enabled and no persisted admin exists, dcrouter does not auto-create an admin account. The Ops dashboard exposes a non-cancelable first-admin bootstrap flow that must be completed explicitly.
Bootstrap behavior:
- `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required.
- The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
- `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
- Optional `idp.global` authentication can be enabled for that local account. The hosted `https://idp.global` endpoint is used by default, `adminAuth.idpGlobalUrl` or `DCROUTER_IDP_GLOBAL_URL` only override it, and the local dcrouter role remains authoritative.
- After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.
## Configuration Model
`DcRouter` is configured with `IDcRouterOptions` from `@serve.zone/dcrouter`.
@@ -120,6 +146,79 @@ dcrouter keeps generated and operator-created routes separate so automation can
System routes are persisted with stable `systemKey` values. API-created routes are the editable route layer intended for operators and automation.
## Route Source Bindings
API-created route records pass ordered `metadata.sourceBindings[]` alongside the SmartProxy route config to express source and path policy variants without duplicating whole routes by hand. Each binding points at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
Runtime behavior:
- Source matching uses the referenced `SourceProfile.security.ipAllowList`.
- Bindings are evaluated in order and the first matching source profile wins.
- A matched binding that exceeds its configured rate or connection limit is terminal and returns `429`; dcrouter does not fall through to later bindings.
- Source-binding rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-binding and path-policy overrides.
- Private-only binding lists are valid. dcrouter adds a same-match terminal deny fallback so unmatched sources fail closed.
- A public or wildcard binding is optional. When present, it must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
- Create/update paths reject source bindings with missing source profiles, source profiles without source matches, or any all-source binding that shadows later bindings; persisted invalid bindings fail closed at compile time.
- Server-side caps bound policy expansion to 16 source bindings, 12 path policies per binding, 64 path patterns per path policy, 256 characters and 8 wildcards per custom path pattern, 512 compiled SmartProxy route-port variants per stored route, and enough priority headroom above the stored route priority for generated source-binding variants.
Path policies let a source binding override rate limits or connection limits for specific path classes. dcrouter currently ships Gitea-oriented classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`. Path-specific variants win over the same binding's fallback; if every path policy is path-specific, dcrouter adds a source-level fallback route for unmatched paths so normal browsing cannot fall through to a later source binding. The Gitea preset keeps `git-smart-http` high-limit and separate from HTML crawling paths so normal `git clone`, `git fetch`, `git push`, and Git LFS traffic are not subject to the lower HTML crawler limits.
```typescript
const trustedProfileId = 'source-profile-id-trusted';
const publicProfileId = 'source-profile-id-public';
const createRoutePayload = {
route: {
name: 'public-gitea',
match: { domains: ['code.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '10.10.0.20', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
metadata: {
sourceBindings: [
{
sourceProfileRef: trustedProfileId,
maxConnections: 5000,
onExceeded: { type: '429' },
},
{
sourceProfileRef: publicProfileId,
onExceeded: { type: '429' },
pathPolicies: [
{
pathClass: 'git-smart-http',
rateLimit: { enabled: true, maxRequests: 1200, window: 60, keyBy: 'ip' },
},
{
pathClass: 'static',
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
},
{
pathClass: 'raw',
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
{
pathClass: 'archive',
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
{
pathClass: 'expensive-html',
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
{
pathClass: 'normal-html',
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
],
},
],
},
};
```
## Production-Flavored Example
```typescript
@@ -184,6 +283,19 @@ const router = new DcRouter({
await router.start();
```
## VPN Target Profiles
Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also be granted to routes whose source policy is meant to evaluate the client's real connecting IP.
dcrouter maps target profiles to SmartProxy VPN client grants. SmartVPN forwards both the real client source IP and authenticated VPN metadata through trusted PROXY v2 headers, so SmartProxy checks source policy and VPN client authorization separately for each connection. Route `security.ipAllowList` and `security.ipBlockList` stay the source of truth for real source-IP policy; `vpnOnly` adds the requirement for authenticated VPN metadata and a matching VPN client grant.
```typescript
const targetProfile = {
name: 'ops laptop source access',
allowRoutesByClientSourceIp: true,
};
```
## Automation API
The OpsServer exposes TypedRequest handlers at `/typedrequest`. You can use raw contracts or the object-oriented API client.
@@ -199,7 +311,7 @@ const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
});
await client.login('admin', 'admin');
await client.login('admin@example.com', 'strong-password');
const route = await client.routes.build()
.setName('api-gateway')
@@ -235,6 +347,21 @@ Supported environment overrides include:
| `DCROUTER_CACHE_ENABLED` | Enables or disables DB-backed persistence. |
| `DCROUTER_MAX_CONNECTIONS`, `DCROUTER_MAX_CONNECTIONS_PER_IP`, `DCROUTER_CONNECTION_RATE_LIMIT` | SmartProxy capacity and rate-limit overrides. |
## Docker Image
Release builds publish a multi-arch OCI image at `code.foss.global/serve.zone/dcrouter:latest` for `linux/amd64` and `linux/arm64`. The image sets `DCROUTER_MODE=OCI_CONTAINER` and starts `node ./cli.js`.
```bash
docker run --rm --name dcrouter \
--network host \
-v dcrouter-data:/data \
-e DCROUTER_BASE_DIR=/data \
-e DCROUTER_TLS_EMAIL=ops@example.com \
code.foss.global/serve.zone/dcrouter:latest
```
Host networking is the simplest container mode for a gateway that owns HTTP/S, SMTP, DNS, RADIUS, remote ingress, and dynamic proxy ports. For narrower deployments, publish only the ports you enable in `IDcRouterOptions` or via the `DCROUTER_*` environment overrides.
## Published Modules
This repository intentionally publishes multiple module boundaries from one codebase.
@@ -279,7 +406,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.
+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();
+49 -3
View File
@@ -47,11 +47,16 @@ const makeApiTokenManager = (scopes: TScope[]) => {
};
};
const setupHandler = (scopes: TScope[]) => {
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,
},
@@ -60,9 +65,13 @@ const setupHandler = (scopes: TScope[]) => {
apiTokenManager: makeApiTokenManager(scopes),
certificateStatusMap: new Map(),
smartProxy: {
routeManager: { getRoutes: () => [] },
settings: options?.certProvisionFunction ? {
certProvisionFunction: options.certProvisionFunction,
} : {},
routeManager: { getRoutes: () => options?.routes ?? [] },
getCertificateStatus: async () => null,
},
certProvisionScheduler: null,
certProvisionScheduler: options?.certProvisionScheduler ?? null,
},
};
@@ -147,6 +156,43 @@ tap.test('CertificateHandler allows API-token import with certificates:write', a
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();
+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();
+305 -1
View File
@@ -2,9 +2,84 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as path from 'path';
import * as fs from 'fs';
import { Buffer } from 'node:buffer';
import * as net from 'node:net';
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function listen(server: net.Server, port: number = 0): Promise<number> {
return await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', () => {
server.off('error', reject);
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : port);
});
});
}
function trackSocket(sockets: Set<net.Socket>, socket: net.Socket): void {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
}
async function closeServer(server: net.Server, sockets?: Set<net.Socket>): Promise<void> {
for (const socket of sockets || []) {
socket.destroy();
}
if (!server.listening) {
return;
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
async function readFirstSocketData(port: number): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const socket = net.connect({ host: '127.0.0.1', port });
let settled = false;
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', onData);
socket.removeListener('error', onError);
socket.removeListener('end', onEnd);
socket.removeListener('close', onClose);
};
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
cleanup();
socket.destroy();
callback();
};
timeout = setTimeout(() => {
settle(() => reject(new Error('Timed out waiting for socket data')));
}, 5000) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
const onData = (data: Buffer) => settle(() => resolve(data.toString('utf8')));
const onError = (error: Error) => settle(() => reject(error));
const onEnd = () => settle(() => reject(new Error('Socket ended before data')));
const onClose = () => settle(() => reject(new Error('Socket closed before data')));
socket.once('data', onData);
socket.once('error', onError);
socket.once('end', onEnd);
socket.once('close', onClose);
});
}
tap.test('DcRouter class - Custom email port configuration', async () => {
// Define custom port mapping
@@ -97,7 +172,10 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
});
expect(customPortRoute).toBeTruthy();
expect(customPortRoute?.name).toEqual('custom-smtp-route');
expect(customPortRoute?.action.type).toEqual('forward');
expect(customPortRoute?.action.targets[0].host).toEqual('localhost');
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
expect(customPortRoute?.remoteIngress).toBeUndefined();
// Check standard port mappings
const smtpRoute = routes.find((r: any) => {
@@ -114,7 +192,185 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
}
});
tap.test('DcRouter class - Generated plaintext email routes hydrate to server-first socket handlers', async () => {
const emailConfig: IUnifiedEmailServerOptions = {
ports: [25, 587, 465],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({ emailConfig });
const routes = (router as any)['generateEmailRoutes'](emailConfig);
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
const hydrate = (routerArg: DcRouter, route: any, origin = 'email') => (routerArg as any)['hydrateStoredRouteForRuntime']({
id: `${origin}-${route.name}`,
route,
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'system',
origin,
systemKey: `${origin}:${route.name}`,
});
const runtimeSmtpRoute = hydrate(router, smtpRoute);
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
const runtimeSubmissionRoute = hydrate(router, submissionRoute);
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
expect(hydrate(router, smtpsRoute)).toBeUndefined();
expect(hydrate(router, smtpRoute, 'api')).toBeUndefined();
const remoteIngressRouter = new DcRouter({
emailConfig,
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const staleSmtpRoute = {
...smtpRoute,
match: {
...smtpRoute.match,
inboundProxyProtocol: undefined,
},
};
const runtimeRemoteSmtpRoute = hydrate(remoteIngressRouter, staleSmtpRoute);
expect(runtimeRemoteSmtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
});
tap.test('DcRouter class - Inbound PROXY policies are applied per listener', async () => {
const router = new DcRouter({
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const routes = (router as any)['applyInboundProxyProtocolPolicies']([{
name: 'remote-route',
match: { ports: [443], domains: ['remote.example.com'] },
remoteIngress: { enabled: true },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
}, {
name: 'same-listener-direct-route',
match: { ports: [443], domains: ['direct.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 9443 }],
},
}]);
expect(routes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
expect(routes[1].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
const vpnRouter = new DcRouter({
vpnConfig: { enabled: true },
});
const vpnRoutes = (vpnRouter as any)['applyInboundProxyProtocolPolicies']([{
name: 'vpn-route',
match: { ports: [9443] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 9443 }],
},
}]);
expect(vpnRoutes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
});
tap.test('DcRouter class - Email socket handler relays server-first SMTP banners', async () => {
const backendSockets = new Set<net.Socket>();
const backend = net.createServer((socket) => {
trackSocket(backendSockets, socket);
socket.write('220 test.example ESMTP Service Ready\r\n');
});
const backendPort = await listen(backend);
const emailConfig: IUnifiedEmailServerOptions = {
ports: [2525],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({
emailConfig,
emailPortConfig: {
portMapping: { 2525: backendPort },
},
});
const routes = (router as any)['generateEmailRoutes'](emailConfig);
const route = routes.find((routeArg: any) => routeArg.name === 'email-port-2525-route');
const runtimeRoute = (router as any)['createServerFirstEmailRuntimeRoute'](route);
expect(runtimeRoute?.action.type).toEqual('socket-handler');
const frontendSockets = new Set<net.Socket>();
const frontend = net.createServer((socket) => {
trackSocket(frontendSockets, socket);
runtimeRoute.action.socketHandler(socket, {
port: 2525,
clientIp: '127.0.0.1',
serverIp: '127.0.0.1',
routeName: route.name,
timestamp: Date.now(),
connectionId: 'test-email-proxy',
});
});
const frontendPort = await listen(frontend);
try {
const banner = await readFirstSocketData(frontendPort);
expect(banner).toEqual('220 test.example ESMTP Service Ready\r\n');
} finally {
await closeServer(frontend, frontendSockets);
await closeServer(backend, backendSockets);
}
});
tap.test('DcRouter class - Email routes are exposed through RemoteIngress when enabled', async () => {
const emailConfig: IUnifiedEmailServerOptions = {
ports: [25, 587, 465],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({
emailConfig,
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const routes = (router as any)['generateEmailRoutes'](emailConfig);
expect(routes.length).toEqual(3);
for (const route of routes) {
expect(route.remoteIngress).toEqual({ enabled: true });
}
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
expect(smtpRoute?.match.transport).toEqual('tcp');
expect(smtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
expect(submissionRoute?.match.transport).toEqual('tcp');
expect(submissionRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
expect(smtpsRoute?.action.type).toEqual('forward');
expect(smtpsRoute?.match.inboundProxyProtocol).toEqual({ mode: 'optional' });
});
tap.test('DcRouter class - Email config with domains and routes', async () => {
const opsServerPort = await getFreePort();
// Create a basic email configuration
const emailConfig: IUnifiedEmailServerOptions = {
ports: [2525],
@@ -129,7 +385,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
tls: {
contactEmail: 'test@example.com'
},
opsServerPort: 3104,
opsServerPort,
dbConfig: {
enabled: false,
}
@@ -151,6 +407,54 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
await router.stop();
});
tap.test('DcRouter class - Email config updates are serialized', async () => {
const router = new DcRouter({
tls: {
contactEmail: 'test@example.com',
},
});
const delay = async () => await new Promise<void>((resolve) => setTimeout(resolve, 10));
let activeLifecycleSteps = 0;
let overlapped = false;
const enterLifecycleStep = async () => {
activeLifecycleSteps++;
if (activeLifecycleSteps > 1) {
overlapped = true;
}
await delay();
activeLifecycleSteps--;
};
(router as any).stopUnifiedEmailComponents = async () => {
await enterLifecycleStep();
};
(router as any).setupUnifiedEmailHandling = async () => {
await enterLifecycleStep();
};
const firstConfig: IUnifiedEmailServerOptions = {
ports: [2525],
hostname: 'first.mail.example.com',
domains: [],
routes: [],
};
const secondConfig: IUnifiedEmailServerOptions = {
ports: [2526],
hostname: 'second.mail.example.com',
domains: [],
routes: [],
};
await Promise.all([
router.updateEmailConfig(firstConfig),
router.updateEmailConfig(secondConfig),
]);
expect(overlapped).toEqual(false);
expect(router.options.emailConfig?.hostname).toEqual('second.mail.example.com');
});
// Final clean-up test
tap.test('clean up after tests', async () => {
// No-op
+94 -44
View File
@@ -1,9 +1,8 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { 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 () => {
@@ -32,6 +31,9 @@ const createTestDb = async () => {
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();
}
@@ -40,6 +42,86 @@ const clearTestState = async () => {
}
};
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();
@@ -328,53 +410,21 @@ tap.test('RouteConfigManager clears remote ingress config when route patch sets
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
});
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
tap.test('DnsManager start does not seed constructor DNS config into DB', 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 || {});
};
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: [] },
});
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();
await dnsManager.start();
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;
}
expect(await DomainDoc.findAll()).toHaveLength(0);
expect(await DnsRecordDoc.findAll()).toHaveLength(0);
});
tap.test('cleanup test db', async () => {
+16 -2
View File
@@ -1,15 +1,29 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import * as plugins from '../ts/plugins.js';
import * as net from 'node:net';
let dcRouter: DcRouter;
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
const opsServerPort = await getFreePort();
dcRouter = new DcRouter({
smartProxyConfig: {
routes: []
},
opsServerPort: 3100,
opsServerPort,
dbConfig: { enabled: false }
});
@@ -146,4 +160,4 @@ tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();
export default tap.start();
+44
View File
@@ -183,6 +183,50 @@ tap.test('EmailDomainManager start merges persisted managed domains after restar
expect(managedDomain?.dnsMode).toEqual('internal-dns');
});
tap.test('EmailDomainManager can resync managed domains after email settings replace runtime config', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('resync-domain', 'resync.example.com', 'provider');
const stored = new EmailDomainDoc();
stored.id = 'resync-email-domain';
stored.domain = 'mail.resync.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();
expect(dcRouterStub.options.emailConfig.domains.some((domain) => domain.domain === 'mail.resync.example.com')).toEqual(true);
dcRouterStub.options.emailConfig = createBaseEmailConfig();
manager.setBaseEmailDomains(dcRouterStub.options.emailConfig.domains);
await manager.syncManagedDomainsToRuntime();
const resyncedDomains = dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain).sort();
expect(resyncedDomains).toEqual(['mail.resync.example.com', 'static.example.com']);
});
tap.test('cleanup', async () => {
const testDb = await testDbPromise;
await clearTestState();
+135
View File
@@ -0,0 +1,135 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { DcRouterDb, EmailServerSettingsDoc } from '../ts/db/index.js';
import { EmailSettingsManager } from '../ts/email/index.js';
import type { IDcRouterOptions } from '../ts/classes.dcrouter.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-email-settings-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-email-settings-${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 clearSettings = async () => {
for (const doc of await EmailServerSettingsDoc.findAll()) {
await doc.delete();
}
};
tap.test('EmailSettingsManager does not backfill from legacy constructor options', async () => {
await testDbPromise;
await clearSettings();
const options: IDcRouterOptions = {
emailConfig: {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
maxMessageSize: 1024,
},
emailPortConfig: {
portMapping: { 25: 10025, 587: 10587 },
},
};
const manager = new EmailSettingsManager(options);
await manager.start();
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(manager.getPublicSettings().hostname).toEqual(null);
expect(options.emailConfig).toBeUndefined();
expect(options.emailPortConfig).toBeUndefined();
await clearSettings();
const migratedDoc = new EmailServerSettingsDoc();
migratedDoc.settingsId = 'email-server-settings';
migratedDoc.enabled = true;
migratedDoc.emailConfig = {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
maxMessageSize: 1024,
};
migratedDoc.emailPortConfig = {
portMapping: { 25: 10025, 587: 10587 },
};
migratedDoc.updatedAt = Date.now();
migratedDoc.updatedBy = 'migration';
await migratedDoc.save();
const secondOptions: IDcRouterOptions = {
emailConfig: {
hostname: 'ignored.example.com',
ports: [2525],
domains: [],
routes: [],
},
};
const secondManager = new EmailSettingsManager(secondOptions);
await secondManager.start();
expect(secondManager.getPublicSettings().hostname).toEqual('mail.example.com');
expect(secondOptions.emailConfig?.hostname).toEqual('mail.example.com');
});
tap.test('EmailSettingsManager updates redacted mutable server settings', async () => {
await testDbPromise;
await clearSettings();
const options: IDcRouterOptions = {};
const manager = new EmailSettingsManager(options);
await manager.start();
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(options.emailConfig).toBeUndefined();
const settings = await manager.updateSettings(
{
enabled: true,
hostname: 'smtp.example.com',
ports: [587, 25, 587],
portMapping: { 25: 10025, 587: 10587 },
maxMessageSize: 2048,
},
'tester',
);
expect(settings.enabled).toEqual(true);
expect(settings.ports).toEqual([25, 587]);
expect(settings.portMapping?.[587]).toEqual(10587);
expect(options.emailConfig?.hostname).toEqual('smtp.example.com');
expect(options.emailConfig?.maxMessageSize).toEqual(2048);
await manager.updateSettings({ enabled: false }, 'tester');
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(options.emailConfig).toBeUndefined();
});
tap.test('cleanup', async () => {
const testDb = await testDbPromise;
await clearSettings();
await testDb.cleanup();
await tap.stopForcefully();
});
export default tap.start();
+232
View File
@@ -0,0 +1,232 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '@push.rocks/smartproxy';
import * as http from 'node:http';
import * as net from 'node:net';
import {
deriveHttpRedirectConfiguration,
deriveHttpRedirects,
} from '../ts/config/helpers.http-redirects.js';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function requestHeaders(
port: number,
path: string,
headers?: Record<string, string>,
): Promise<http.IncomingMessage> {
return await new Promise<http.IncomingMessage>((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
request.once('error', reject);
});
}
tap.test('deriveHttpRedirectConfiguration creates active runtime redirects from HTTPS routes', async () => {
const result = deriveHttpRedirectConfiguration([
{
id: 'route-1',
name: 'app-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
remoteIngress: {
enabled: true,
edgeFilter: ['edge-a'],
},
} as any,
]);
expect(result.redirects.length).toEqual(1);
expect(result.redirects[0].status).toEqual('active');
expect(result.redirects[0].domainPattern).toEqual('app.example.com');
expect(result.redirects[0].remoteIngress).toEqual(true);
expect(result.runtimeRoutes.length).toEqual(1);
expect(result.runtimeRoutes[0].match.ports).toEqual(80);
expect(result.runtimeRoutes[0].match.domains).toEqual('app.example.com');
expect(result.runtimeRoutes[0].priority).toEqual(0);
expect(result.runtimeRoutes[0].remoteIngress).toEqual({ enabled: true, edgeFilter: ['edge-a'] });
expect(typeof result.runtimeRoutes[0].action.socketHandler).toEqual('function');
});
tap.test('deriveHttpRedirectConfiguration deduplicates identical redirect scopes', async () => {
const redirects = deriveHttpRedirects([
{
id: 'route-1',
name: 'first-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
id: 'route-2',
name: 'second-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8081 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
]);
expect(redirects.length).toEqual(1);
expect(redirects[0].sourceRouteNames).toEqual(['first-route', 'second-route']);
});
tap.test('deriveHttpRedirectConfiguration treats broad explicit HTTP routes as covered', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'existing-http-route',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects.length).toEqual(1);
expect(result.redirects[0].status).toEqual('covered');
expect(result.redirects[0].coveredByRouteNames).toEqual(['existing-http-route']);
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration skips broad redirects that overlap path-specific HTTP routes', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'existing-http-health-route',
match: { ports: 80, domains: 'app.example.com', path: '/health' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects[0].status).toEqual('skipped');
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration skips wildcard redirects that overlap explicit HTTP domains', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'wildcard-https-route',
match: { ports: 443, domains: '*.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'explicit-http-app-route',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects[0].status).toEqual('skipped');
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration ignores non-web or narrowed HTTPS routes', async () => {
const redirects = deriveHttpRedirects([
{
name: 'udp-route',
match: { ports: 443, domains: 'udp.example.com', transport: 'udp' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 443 }],
tls: { mode: 'passthrough' },
},
} as any,
{
name: 'header-route',
match: { ports: 443, domains: 'header.example.com', headers: { 'x-test': 'yes' } },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'socket-handler-route',
match: { ports: 443, domains: 'handler.example.com' },
action: {
type: 'socket-handler',
socketHandler: () => {},
},
} as any,
]);
expect(redirects.length).toEqual(0);
});
tap.test('generated runtime redirect preserves host and path', async () => {
const proxyPort = await getFreePort();
const redirectRoute = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
]).runtimeRoutes[0] as any;
redirectRoute.match = { ...redirectRoute.match, ports: proxyPort };
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [redirectRoute],
});
try {
await proxy.start();
const response = await requestHeaders(proxyPort, '/some/path?x=1', { host: 'app.example.com' });
expect(response.statusCode).toEqual(301);
expect(response.headers.location).toEqual('https://app.example.com/some/path?x=1');
response.destroy();
} finally {
await proxy.stop();
}
});
export default tap.start();
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let identity: interfaces.data.IIdentity;
let opsServerPort: number;
const testAdminPassword = 'test-admin-password';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function getTypedRequestUrl(): string {
return `http://localhost:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3102,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login with admin credentials and receive JWT', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -48,7 +67,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
tap.test('should verify valid JWT identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -68,7 +87,7 @@ tap.test('should verify valid JWT identity', async () => {
tap.test('should reject invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -85,7 +104,7 @@ tap.test('should reject invalid JWT', async () => {
tap.test('should verify JWT matches identity data', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -106,7 +125,7 @@ tap.test('should verify JWT matches identity data', async () => {
tap.test('should handle logout', async () => {
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLogout'
);
@@ -120,7 +139,7 @@ tap.test('should handle logout', async () => {
tap.test('should reject wrong credentials', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
+139 -3
View File
@@ -14,6 +14,38 @@ const emptyProtocolDistribution = {
otherTotal: 0,
};
function createActiveConnectionSnapshots(entries: Array<{
count: number;
sourceIp?: string;
routeId?: string;
domain?: string;
localPort?: number;
}>) {
const snapshots: any[] = [];
let index = 0;
for (const entry of entries) {
for (let i = 0; i < entry.count; i++) {
snapshots.push({
id: `test-connection-${index++}`,
sourceIp: entry.sourceIp || '192.0.2.10',
sourcePort: 40000 + index,
localPort: entry.localPort || 443,
domain: entry.domain,
routeId: entry.routeId,
targetHost: '127.0.0.1',
targetPort: 8443,
protocol: 'https',
state: 'active',
startedAtMs: Date.now(),
ageMs: 0,
bytesIn: 0,
bytesOut: 0,
});
}
}
return snapshots;
}
function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>;
@@ -22,14 +54,21 @@ function createProxyMetrics(args: {
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: () => new Map<string, number>(),
topIPs: () => [],
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,
@@ -42,7 +81,7 @@ function createProxyMetrics(args: {
custom: () => ({ in: 0, out: 0 }),
history: () => [],
byRoute: () => args.throughputByRoute,
byIP: () => new Map<string, { in: number; out: number }>(),
byIP: () => throughputByIP,
},
requests: {
perSecond: () => 0,
@@ -83,6 +122,10 @@ tap.test('MetricsManager joins domain activity to id-keyed route metrics', async
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 3, routeId: 'route-id-only', domain: 'alpha.example.com' },
{ count: 1, routeId: 'route-id-only', domain: 'beta.example.com' },
]),
routeManager: {
getRoutes: () => [
{
@@ -143,6 +186,9 @@ tap.test('MetricsManager prefers live domain request rates for current activity'
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 10, routeId: 'route-id-only', domain: 'beta.example.com' },
]),
routeManager: {
getRoutes: () => [
{
@@ -224,6 +270,7 @@ tap.test('MetricsManager does not duplicate backend active counts onto protocol
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => [],
routeManager: {
getRoutes: () => [],
},
@@ -239,4 +286,93 @@ tap.test('MetricsManager does not duplicate backend active counts onto protocol
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
});
tap.test('MetricsManager queues IP intelligence without awaiting enrichment', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['1.1.1.1', 2],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['1.1.1.1', { in: 1500, out: 1000 }],
]),
});
const queuedIps: string[][] = [];
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 4, sourceIp: '8.8.8.8' },
{ count: 2, sourceIp: '1.1.1.1' },
]),
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
listIpIntelligence: async () => [],
},
} as any);
await manager.getNetworkStats();
expect(queuedIps).toHaveLength(1);
expect(queuedIps[0]).toContain('8.8.8.8');
expect(queuedIps[0]).toContain('1.1.1.1');
});
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['8.8.4.4', 3],
['1.1.1.1', 5],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['8.8.4.4', { in: 700, out: 350 }],
['1.1.1.1', { in: 2000, out: 1000 }],
]),
});
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 4, sourceIp: '8.8.8.8' },
{ count: 3, sourceIp: '8.8.4.4' },
{ count: 5, sourceIp: '1.1.1.1' },
]),
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: () => undefined,
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
},
} as any);
const stats = await manager.getNetworkStats();
expect(stats.topASNs).toHaveLength(2);
expect(stats.topASNs[0].asn).toEqual(15169);
expect(stats.topASNs[0].organization).toEqual('Google LLC');
expect(stats.topASNs[0].activeConnections).toEqual(7);
expect(stats.topASNs[0].ipCount).toEqual(2);
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
expect(stats.topASNs[1].asn).toEqual(13335);
expect(stats.topASNs[1].activeConnections).toEqual(5);
});
export default tap.start();
+436
View File
@@ -0,0 +1,436 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createMigrationRunner } from '../ts_migrations/index.js';
function setPath(target: Record<string, any>, path: string, value: unknown): void {
const parts = path.split('.');
let cursor = target;
for (const part of parts.slice(0, -1)) {
cursor[part] = cursor[part] || {};
cursor = cursor[part];
}
cursor[parts[parts.length - 1]] = value;
}
function getPath(target: Record<string, any>, path: string): unknown {
let cursor: any = target;
for (const part of path.split('.')) {
if (cursor === null || cursor === undefined) return undefined;
cursor = cursor[part];
}
return cursor;
}
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
for (const [key, value] of Object.entries(set)) {
setPath(document, key, value);
}
}
function unsetPath(target: Record<string, any>, path: string): void {
const parts = path.split('.');
let cursor: any = target;
for (const part of parts.slice(0, -1)) {
if (cursor?.[part] === undefined) return;
cursor = cursor[part];
}
if (cursor && typeof cursor === 'object') {
delete cursor[parts[parts.length - 1]];
}
}
function applyUnset(document: Record<string, any>, unset: Record<string, unknown>): void {
for (const key of Object.keys(unset)) {
unsetPath(document, key);
}
}
function matchesQuery(document: Record<string, any>, query: Record<string, any>): boolean {
for (const [key, expected] of Object.entries(query)) {
const actual = getPath(document, key);
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
if ('$exists' in expected) {
const exists = actual !== undefined;
if (exists !== Boolean(expected.$exists)) return false;
continue;
}
if ('$type' in expected) {
if (expected.$type === 'string' && typeof actual !== 'string') return false;
continue;
}
if ('$in' in expected) {
if (!Array.isArray(expected.$in) || !expected.$in.includes(actual)) return false;
continue;
}
}
if (actual !== expected) return false;
}
return true;
}
function createFakeCollection(documents: Array<Record<string, any>> = []) {
return {
findOne: async (query: Record<string, any> = {}) => {
const document = documents.find((candidate) => matchesQuery(candidate, query));
return document ? structuredClone(document) : null;
},
find: (query: Record<string, any> = {}) => ({
async *[Symbol.asyncIterator]() {
for (const document of documents) {
if (matchesQuery(document, query)) {
yield structuredClone(document);
}
}
},
}),
insertOne: async (document: Record<string, any>) => {
documents.push(structuredClone(document));
return { insertedId: document._id || document.id };
},
updateMany: async (query: Record<string, any>, update: any) => {
let modifiedCount = 0;
for (const document of documents) {
if (!matchesQuery(document, query)) continue;
applySet(document, update.$set || {});
applyUnset(document, update.$unset || {});
modifiedCount++;
}
return { modifiedCount };
},
updateOne: async (query: Record<string, any>, update: any) => {
const document = documents.find((candidate) => matchesQuery(candidate, query));
if (!document) return { matchedCount: 0, modifiedCount: 0, upsertedCount: 0 };
applySet(document, update.$set || {});
applyUnset(document, update.$unset || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
}
function createFakeDb(
currentVersion: string,
collections: Record<string, Array<Record<string, any>>> = {},
) {
const ledgerDocument = {
nameId: 'smartmigration:smartmigration',
data: {
currentVersion,
steps: {},
lock: { holder: null, acquiredAt: null, expiresAt: null },
checkpoints: {},
},
};
const fakeCollections = new Map(
Object.entries(collections).map(([name, documents]) => [name, createFakeCollection(documents)]),
);
const emptyCollection = createFakeCollection();
const ledgerCollection = {
createIndex: async () => undefined,
findOne: async () => structuredClone(ledgerDocument),
findOneAndUpdate: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return structuredClone(ledgerDocument);
},
updateOne: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
return {
mongoDb: {
collection: (name: string) =>
name === 'SmartdataEasyStore'
? ledgerCollection
: fakeCollections.get(name) || emptyCollection,
},
};
}
tap.test('migration runner applies schema steps through the current target', async () => {
const sourceProfiles: Array<Record<string, any>> = [];
const runner = await createMigrationRunner(
createFakeDb('13.16.0', { SourceProfileDoc: sourceProfiles }),
'13.42.0',
);
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.42.0');
expect(result.stepsApplied).toHaveLength(4);
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('PUBLIC');
});
tap.test('migration runner uses exact SmartData collection names for DNS source renames', async () => {
const domains: Array<Record<string, any>> = [{ _id: 'domain-1', source: 'manual' }];
const records: Array<Record<string, any>> = [{ _id: 'record-1', source: 'manual' }];
const runner = await createMigrationRunner(
createFakeDb('13.1.0', {
DomainDoc: domains,
DnsRecordDoc: records,
}),
'13.8.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(2);
expect(domains[0].source).toEqual('dcrouter');
expect(records[0].source).toEqual('local');
});
tap.test('migration runner rematerializes source-profile-backed route security', async () => {
const profiles: Array<Record<string, any>> = [
{
_id: 'profile-doc-1',
id: 'standard-profile',
name: 'Standard',
security: {
ipAllowList: ['192.168.*', '127.0.0.1'],
maxConnections: 1000,
},
},
];
const routes: Array<Record<string, any>> = [
{
_id: 'route-doc-1',
id: 'route-1',
route: {
name: 'Public service domains',
match: { ports: 443, domains: ['code.foss.global'] },
action: { type: 'forward', targets: [{ host: '192.168.5.247', port: 443 }] },
security: {
ipAllowList: ['192.168.*', '*'],
maxConnections: 1000,
},
},
metadata: {
sourceProfileRef: 'standard-profile',
sourceProfileName: 'Standard',
},
updatedAt: 1,
},
];
const runner = await createMigrationRunner(
createFakeDb('13.40.1', {
SourceProfileDoc: profiles,
RouteDoc: routes,
}),
'13.40.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(routes[0].route.security.ipAllowList.includes('*')).toBeFalse();
expect(routes[0].route.security.ipAllowList).toContain('192.168.*');
expect(routes[0].route.security.maxConnections).toEqual(1000);
expect(routes[0].metadata.lastResolvedAt).toBeTruthy();
});
tap.test('migration runner seeds only missing default source profiles', async () => {
const sourceProfiles: Array<Record<string, any>> = [
{
id: 'public-profile',
name: 'PUBLIC',
description: 'Existing public profile',
security: { ipAllowList: ['*'] },
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.40.2', { SourceProfileDoc: sourceProfiles }),
'13.42.0',
);
const result = await runner.run();
const publicProfiles = sourceProfiles.filter((profile) => profile.name === 'PUBLIC');
expect(result.stepsApplied).toHaveLength(1);
expect(sourceProfiles).toHaveLength(3);
expect(publicProfiles).toHaveLength(1);
expect(publicProfiles[0].security.rateLimit).toBeUndefined();
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
});
tap.test('migration runner converts legacy route access metadata to source bindings', async () => {
const profiles: Array<Record<string, any>> = [
{
_id: 'profile-doc-1',
id: 'standard-profile',
name: 'Standard',
security: { ipAllowList: ['10.0.0.0/8'] },
},
{
_id: 'profile-doc-2',
id: 'public-profile',
name: 'PUBLIC',
security: { ipAllowList: ['*'] },
},
];
const routes: Array<Record<string, any>> = [
{
_id: 'route-doc-1',
id: 'route-1',
route: {
name: 'standard service',
match: { ports: 443, domains: ['onebox.example.com'] },
action: { type: 'forward', targets: [{ host: '10.0.0.2', port: 443 }] },
security: { ipAllowList: ['10.0.0.0/8'], maxConnections: 1000 },
},
metadata: {
sourceProfileRef: 'standard-profile',
sourceProfileName: 'Old Standard Name',
},
updatedAt: 1,
},
{
_id: 'route-doc-2',
id: 'route-2',
route: {
name: 'gitea',
match: { ports: 443, domains: ['code.example.com'] },
action: { type: 'forward', targets: [{ host: '10.0.0.3', port: 3000 }] },
security: { basicAuth: { username: 'user', password: 'pass' } },
},
metadata: {
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'standard-profile' },
{ sourceProfileRef: 'public-profile' },
],
},
},
updatedAt: 1,
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.1', {
SourceProfileDoc: profiles,
RouteDoc: routes,
}),
'13.43.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(routes[0].metadata.sourceBindings).toEqual([
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Old Standard Name' },
]);
expect(routes[0].metadata.sourceProfileRef).toBeUndefined();
expect(routes[0].metadata.sourceProfileName).toBeUndefined();
expect(routes[0].metadata.sourcePolicy).toBeUndefined();
expect(routes[0].route.security).toBeUndefined();
expect(routes[1].metadata.sourceBindings).toEqual([
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Standard' },
{ sourceProfileRef: 'public-profile', sourceProfileName: 'PUBLIC' },
]);
expect(routes[1].metadata.sourcePolicy).toBeUndefined();
expect(routes[1].route.security.basicAuth.username).toEqual('user');
});
tap.test('migration runner backfills RemoteIngress hub settings from legacy config seed', async () => {
const hubSettingsDocs: Array<Record<string, any>> = [
{
_id: 'remote-ingress-settings-1',
settingsId: 'remote-ingress-hub-settings',
performance: undefined,
updatedAt: 1,
updatedBy: '',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.5', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
'13.43.6',
{
remoteIngressHubSettings: {
enabled: true,
tunnelPort: 29443,
hubDomain: '203.0.113.10',
performance: {
profile: 'balanced',
maxStreamsPerEdge: 10000,
},
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(hubSettingsDocs[0].enabled).toEqual(true);
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
expect(hubSettingsDocs[0].hubDomain).toEqual('203.0.113.10');
expect(hubSettingsDocs[0].performance.profile).toEqual('balanced');
expect(hubSettingsDocs[0].performance.maxStreamsPerEdge).toEqual(10000);
expect(hubSettingsDocs[0].updatedAt).not.toEqual(1);
});
tap.test('migration runner backfills RemoteIngress hub settings at current package target', async () => {
const hubSettingsDocs: Array<Record<string, any>> = [
{
_id: 'remote-ingress-settings-current',
settingsId: 'remote-ingress-hub-settings',
updatedAt: 1,
updatedBy: '',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.2', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
'13.43.5',
{
remoteIngressHubSettings: {
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(hubSettingsDocs[0].enabled).toEqual(true);
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
expect(hubSettingsDocs[0].hubDomain).toEqual('ingress.example.com');
});
tap.test('migration runner backfills Email server settings from legacy config seed', async () => {
const emailSettingsDocs: Array<Record<string, any>> = [];
const runner = await createMigrationRunner(
createFakeDb('13.43.2', { EmailServerSettingsDoc: emailSettingsDocs }),
'13.43.5',
{
emailServerSettings: {
enabled: true,
emailConfig: {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
},
emailPortConfig: {
portMapping: { 25: 10025, 587: 10587 },
},
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(emailSettingsDocs).toHaveLength(1);
expect(emailSettingsDocs[0].enabled).toEqual(true);
expect(emailSettingsDocs[0].emailConfig.hostname).toEqual('mail.example.com');
expect(emailSettingsDocs[0].emailPortConfig.portMapping[25]).toEqual(10025);
});
export default tap.start();
+20
View File
@@ -0,0 +1,20 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { getOciContainerConfig } from '../ts_oci_container/index.js';
tap.test('OCI config should accept explicit DNS bind interface', async () => {
const previousValue = process.env.DCROUTER_DNS_BIND_INTERFACE;
process.env.DCROUTER_DNS_BIND_INTERFACE = '192.168.190.3';
try {
const config = getOciContainerConfig();
expect(config.dnsBindInterface).toEqual('192.168.190.3');
} finally {
if (previousValue === undefined) {
delete process.env.DCROUTER_DNS_BIND_INTERFACE;
} else {
process.env.DCROUTER_DNS_BIND_INTERFACE = previousValue;
}
}
});
export default tap.start();
+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();
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
let opsServerPort: number;
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function typedRequestUrl(): string {
return `http://127.0.0.1:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3101,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -40,7 +59,7 @@ tap.test('should login as admin', async () => {
tap.test('should respond to health status request', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getHealthStatus'
);
@@ -56,7 +75,7 @@ tap.test('should respond to health status request', async () => {
tap.test('should respond to server statistics request', async () => {
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getServerStatistics'
);
@@ -73,7 +92,7 @@ tap.test('should respond to server statistics request', async () => {
tap.test('should respond to configuration request', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getConfiguration'
);
@@ -94,7 +113,7 @@ tap.test('should respond to configuration request', async () => {
tap.test('should handle log retrieval request', async () => {
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getRecentLogs'
);
@@ -111,7 +130,7 @@ tap.test('should handle log retrieval request', async () => {
tap.test('should reject unauthenticated requests', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getHealthStatus'
);
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
let opsServerPort: number;
const testAdminPassword = 'test-admin-password';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function getTypedRequestUrl(): string {
return `http://localhost:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3103,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -41,7 +60,7 @@ tap.test('should login as admin', async () => {
tap.test('should allow admin to verify identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -56,7 +75,7 @@ tap.test('should allow admin to verify identity', async () => {
tap.test('should reject verify identity without identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -71,7 +90,7 @@ tap.test('should reject verify identity without identity', async () => {
tap.test('should reject verify identity with invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -91,7 +110,7 @@ tap.test('should reject verify identity with invalid JWT', async () => {
tap.test('should reject protected endpoints without auth', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'getHealthStatus'
);
@@ -107,7 +126,7 @@ tap.test('should reject protected endpoints without auth', async () => {
tap.test('should allow authenticated access to protected endpoints', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'getConfiguration'
);
+48 -198
View File
@@ -3,10 +3,6 @@ import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Helpers: access private maps for direct unit testing without DB
// ============================================================================
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
@@ -54,10 +50,6 @@ function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
} as IRouteConfig;
}
// ============================================================================
// Resolution tests
// ============================================================================
let resolver: ReferenceResolver;
tap.test('should create ReferenceResolver instance', async () => {
@@ -67,79 +59,43 @@ tap.test('should create ReferenceResolver instance', async () => {
tap.test('should list empty profiles and targets initially', async () => {
expect(resolver.listProfiles()).toBeArray();
expect(resolver.listProfiles().length).toEqual(0);
expect(resolver.listProfiles()).toHaveLength(0);
expect(resolver.listTargets()).toBeArray();
expect(resolver.listTargets().length).toEqual(0);
expect(resolver.listTargets()).toHaveLength(0);
});
// ---- Source profile resolution ----
tap.test('should resolve source profile onto a route', async () => {
tap.test('should resolve source binding display names without materializing route security', async () => {
const profile = makeProfile();
injectProfile(resolver, profile);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const route = makeRoute({
security: { ipAllowList: ['127.0.0.1'], maxConnections: 42 },
});
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
};
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeTruthy();
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.maxConnections).toEqual(1000);
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.route.security!.ipAllowList).toEqual(['127.0.0.1']);
expect(result.route.security!.maxConnections).toEqual(42);
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should merge inline route security with profile security', async () => {
const route = makeRoute({
security: {
ipAllowList: ['127.0.0.1'],
maxConnections: 5000,
},
});
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
// IP lists are unioned
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
// Inline maxConnections overrides profile
expect(result.route.security!.maxConnections).toEqual(5000);
});
tap.test('should deduplicate IP lists during merge', async () => {
const route = makeRoute({
security: {
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
},
});
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
expect(count).toEqual(1);
});
tap.test('should handle missing profile gracefully', async () => {
tap.test('should keep missing source binding refs fail-closed for compiler validation', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'nonexistent-profile' }],
};
const result = resolver.resolveRoute(route, metadata);
// Route should be unchanged
expect(result.route.security).toBeUndefined();
expect(result.metadata.sourceProfileName).toBeUndefined();
expect(result.metadata.sourceBindings![0].sourceProfileName).toBeUndefined();
});
// ---- Profile inheritance ----
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
tap.test('should resolve source profile inheritance for apply-time compiler use', async () => {
const baseProfile = makeProfile({
id: 'base-profile',
name: 'BASE',
@@ -160,46 +116,12 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
});
injectProfile(resolver, extendedProfile);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
const result = resolver.resolveRoute(route, metadata);
// Should have IPs from both base and extended profiles
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
// maxConnections from base (extended doesn't override)
expect(result.route.security!.maxConnections).toEqual(500);
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
const security = resolver.resolveSourceProfileSecurity('extended-profile')!;
expect(security.ipAllowList).toContain('10.0.0.0/8');
expect(security.ipAllowList).toContain('160.79.104.0/21');
expect(security.maxConnections).toEqual(500);
});
tap.test('should detect circular profile inheritance', async () => {
const profileA = makeProfile({
id: 'circular-a',
name: 'A',
security: { ipAllowList: ['1.1.1.1'] },
extendsProfiles: ['circular-b'],
});
const profileB = makeProfile({
id: 'circular-b',
name: 'B',
security: { ipAllowList: ['2.2.2.2'] },
extendsProfiles: ['circular-a'],
});
injectProfile(resolver, profileA);
injectProfile(resolver, profileB);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
// Should not infinite loop — resolves what it can
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeTruthy();
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
});
// ---- Network target resolution ----
tap.test('should resolve network target onto a route', async () => {
const target = makeTarget();
injectTarget(resolver, target);
@@ -209,86 +131,34 @@ tap.test('should resolve network target onto a route', async () => {
const result = resolver.resolveRoute(route, metadata);
expect(result.route.action.targets).toBeTruthy();
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
expect(result.route.action.targets![0].port).toEqual(443);
expect(result.metadata.networkTargetName).toEqual('INFRA');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should handle missing target gracefully', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
const result = resolver.resolveRoute(route, metadata);
// Route targets should be unchanged (still the placeholder)
expect(result.route.action.targets![0].host).toEqual('placeholder');
expect(result.metadata.networkTargetName).toBeUndefined();
});
// ---- Combined resolution ----
tap.test('should resolve both profile and target simultaneously', async () => {
tap.test('should resolve source bindings and target references together', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
sourceProfileRef: 'profile-1',
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
networkTargetRef: 'target-1',
};
const result = resolver.resolveRoute(route, metadata);
// Security from profile
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.maxConnections).toEqual(1000);
// Target from network target
expect(result.route.security).toBeUndefined();
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
expect(result.route.action.targets![0].port).toEqual(443);
// Both names recorded
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
expect(result.metadata.networkTargetName).toEqual('INFRA');
});
tap.test('should skip resolution when no metadata refs', async () => {
const route = makeRoute({
security: { ipAllowList: ['1.2.3.4'] },
});
const metadata: IRouteMetadata = {};
const result = resolver.resolveRoute(route, metadata);
// Route should be completely unchanged
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
expect(result.route.security!.ipAllowList!.length).toEqual(1);
expect(result.route.action.targets![0].host).toEqual('placeholder');
});
tap.test('should be idempotent — resolving twice gives same result', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
sourceProfileRef: 'profile-1',
networkTargetRef: 'target-1',
};
const first = resolver.resolveRoute(route, metadata);
const second = resolver.resolveRoute(first.route, first.metadata);
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
});
// ---- Lookup helpers ----
tap.test('should find routes by profile ref (sync)', async () => {
tap.test('should find routes by source binding profile ref only', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-a', {
id: 'route-a',
route: makeRoute({ name: 'route-a' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1' },
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
});
storedRoutes.set('route-b', {
id: 'route-b',
@@ -300,37 +170,31 @@ tap.test('should find routes by profile ref (sync)', async () => {
id: 'route-c',
route: makeRoute({ name: 'route-c' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
metadata: {
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
networkTargetRef: 'target-1',
},
});
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
expect(profileRefs.length).toEqual(2);
expect(profileRefs).toHaveLength(2);
expect(profileRefs).toContain('route-a');
expect(profileRefs).toContain('route-c');
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
expect(targetRefs.length).toEqual(2);
expect(targetRefs).toHaveLength(2);
expect(targetRefs).toContain('route-b');
expect(targetRefs).toContain('route-c');
});
tap.test('should get profile usage for a specific profile ID', async () => {
tap.test('should get profile and target usage for specific IDs', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-x', {
id: 'route-x',
route: makeRoute({ name: 'my-route' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1' },
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
});
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
expect(usage.length).toEqual(1);
expect(usage[0].id).toEqual('route-x');
expect(usage[0].routeName).toEqual('my-route');
});
tap.test('should get target usage for a specific target ID', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-y', {
id: 'route-y',
route: makeRoute({ name: 'other-route' }),
@@ -338,34 +202,20 @@ tap.test('should get target usage for a specific target ID', async () => {
metadata: { networkTargetRef: 'target-1' },
});
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
expect(usage.length).toEqual(1);
expect(usage[0].id).toEqual('route-y');
expect(usage[0].routeName).toEqual('other-route');
const profileUsage = resolver.getProfileUsageForId('profile-1', storedRoutes);
expect(profileUsage).toHaveLength(1);
expect(profileUsage[0].routeName).toEqual('my-route');
const targetUsage = resolver.getTargetUsageForId('target-1', storedRoutes);
expect(targetUsage).toHaveLength(1);
expect(targetUsage[0].routeName).toEqual('other-route');
});
// ---- Profile/target getters ----
tap.test('should get profile by name', async () => {
const profile = resolver.getProfileByName('STANDARD');
expect(profile).toBeTruthy();
expect(profile!.id).toEqual('profile-1');
});
tap.test('should get target by name', async () => {
const target = resolver.getTargetByName('INFRA');
expect(target).toBeTruthy();
expect(target!.id).toEqual('target-1');
});
tap.test('should return undefined for nonexistent profile name', async () => {
const profile = resolver.getProfileByName('NONEXISTENT');
expect(profile).toBeUndefined();
});
tap.test('should return undefined for nonexistent target name', async () => {
const target = resolver.getTargetByName('NONEXISTENT');
expect(target).toBeUndefined();
tap.test('should get profiles and targets by name', async () => {
expect(resolver.getProfileByName('STANDARD')!.id).toEqual('profile-1');
expect(resolver.getTargetByName('INFRA')!.id).toEqual('target-1');
expect(resolver.getProfileByName('NONEXISTENT')).toBeUndefined();
expect(resolver.getTargetByName('NONEXISTENT')).toBeUndefined();
});
export default tap.start();
+71
View File
@@ -0,0 +1,71 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { RemoteIngressManager } from '../ts/remoteingress/index.js';
import { RemoteIngressHubSettingsDoc } from '../ts/db/index.js';
tap.test('RemoteIngressManager preserves omitted hub settings on partial update', async () => {
const originalLoad = RemoteIngressHubSettingsDoc.load;
const fakeDoc: any = {
settingsId: 'remote-ingress-hub-settings',
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
performance: {
totalWindowBudgetBytes: 134217728,
},
updatedAt: 1,
updatedBy: 'seed',
save: async () => undefined,
};
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
try {
const manager = new RemoteIngressManager();
const settings = await manager.updateHubSettings({
performance: {
maxStreamsPerEdge: 10000,
},
}, 'test-user');
expect(settings.enabled).toEqual(true);
expect(settings.tunnelPort).toEqual(29443);
expect(settings.hubDomain).toEqual('ingress.example.com');
expect(settings.performance?.maxStreamsPerEdge).toEqual(10000);
} finally {
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
}
});
tap.test('RemoteIngressManager clears optional hub settings explicitly', async () => {
const originalLoad = RemoteIngressHubSettingsDoc.load;
const fakeDoc: any = {
settingsId: 'remote-ingress-hub-settings',
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
performance: {
maxStreamsPerEdge: 10000,
},
updatedAt: 1,
updatedBy: 'seed',
save: async () => undefined,
};
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
try {
const manager = new RemoteIngressManager();
const settings = await manager.updateHubSettings({
hubDomain: null,
performance: null,
}, 'test-user');
expect(settings.enabled).toEqual(true);
expect(settings.tunnelPort).toEqual(29443);
expect(settings.hubDomain).toBeUndefined();
expect(settings.performance).toBeUndefined();
} finally {
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
}
});
export default tap.start();
+71
View File
@@ -40,6 +40,23 @@ const clearTestState = async () => {
}
};
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();
@@ -120,6 +137,60 @@ tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot
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();
+296
View File
@@ -0,0 +1,296 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '@push.rocks/smartproxy';
import { Buffer } from 'node:buffer';
import * as http from 'node:http';
import * as net from 'node:net';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function startBackend(
handler: http.RequestListener = (_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
},
): Promise<{ server: http.Server; port: number }> {
const server = http.createServer(handler);
const port = await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : 0);
});
});
return { server, port };
}
async function closeServer(server: http.Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
async function requestHeaders(
port: number,
path: string,
headers?: Record<string, string>,
): Promise<http.IncomingMessage> {
return await new Promise<http.IncomingMessage>((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
request.once('error', reject);
});
}
async function readResponseBody(response: http.IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of response) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
tap.test('SmartProxy route rateLimit returns 429 after threshold', async () => {
const backend = await startBackend();
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
name: 'rate-limit-smoke',
match: {
ports: proxyPort,
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: backend.port }],
},
security: {
rateLimit: {
enabled: true,
maxRequests: 1,
window: 60,
keyBy: 'ip',
errorMessage: 'too many requests',
},
},
},
],
});
try {
await proxy.start();
const firstResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
const secondResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
const firstBody = await firstResponse.text();
const secondBody = await secondResponse.text();
expect(firstResponse.status).toEqual(200);
expect(firstBody).toEqual('ok');
expect(secondResponse.status).toEqual(429);
expect(secondBody).toContain('too many requests');
} finally {
await Promise.allSettled([
proxy.stop(),
closeServer(backend.server),
]);
}
});
tap.test('SmartProxy rateLimit is terminal and does not fall through to a lower priority route', async () => {
const limitedBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('limited');
});
const fallbackBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('fallback');
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'terminal-rate-limit',
name: 'terminal-rate-limit',
priority: 10,
match: { ports: proxyPort, domains: 'limited.local' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
},
security: {
rateLimit: {
enabled: true,
maxRequests: 1,
window: 60,
keyBy: 'ip',
errorMessage: 'limited route exceeded',
},
},
},
{
id: 'lower-priority-fallback',
name: 'lower-priority-fallback',
priority: 0,
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
},
},
],
});
try {
await proxy.start();
const firstResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
const secondResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
const firstBody = await readResponseBody(firstResponse);
const secondBody = await readResponseBody(secondResponse);
expect(firstResponse.statusCode).toEqual(200);
expect(firstBody).toEqual('limited');
expect(secondResponse.statusCode).toEqual(429);
expect(secondBody).toContain('limited route exceeded');
expect(secondBody.includes('fallback')).toBeFalse();
} finally {
await Promise.allSettled([
proxy.stop(),
closeServer(limitedBackend.server),
closeServer(fallbackBackend.server),
]);
}
});
tap.test('SmartProxy route maxConnections returns 429 when concurrent limit is exceeded', async () => {
let firstResponse: http.IncomingMessage | undefined;
let secondResponse: http.IncomingMessage | undefined;
let releaseResponse: (() => void) | undefined;
const releasePromise = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const backend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.flushHeaders();
void releasePromise.then(() => response.end('released'));
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'max-connections-smoke',
name: 'max-connections-smoke',
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: backend.port }],
},
security: {
maxConnections: 1,
},
},
],
});
try {
await proxy.start();
firstResponse = await requestHeaders(proxyPort, '/hold');
secondResponse = await requestHeaders(proxyPort, '/blocked');
expect(firstResponse.statusCode).toEqual(200);
expect(secondResponse.statusCode).toEqual(429);
const secondBody = await readResponseBody(secondResponse);
releaseResponse?.();
expect(await readResponseBody(firstResponse)).toEqual('released');
expect(secondBody.length > 0).toBeTrue();
} finally {
releaseResponse?.();
firstResponse?.destroy();
secondResponse?.destroy();
await Promise.allSettled([
proxy.stop(),
closeServer(backend.server),
]);
}
});
tap.test('SmartProxy maxConnections is terminal and does not fall through to a lower priority route', async () => {
let firstResponse: http.IncomingMessage | undefined;
let secondResponse: http.IncomingMessage | undefined;
let releaseResponse: (() => void) | undefined;
const releasePromise = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const limitedBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.flushHeaders();
void releasePromise.then(() => response.end('limited released'));
});
const fallbackBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('fallback');
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'terminal-max-connections',
name: 'terminal-max-connections',
priority: 10,
match: { ports: proxyPort, domains: 'limited.local' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
},
security: {
maxConnections: 1,
},
},
{
id: 'max-connections-lower-priority-fallback',
name: 'max-connections-lower-priority-fallback',
priority: 0,
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
},
},
],
});
try {
await proxy.start();
firstResponse = await requestHeaders(proxyPort, '/hold', { host: 'limited.local' });
secondResponse = await requestHeaders(proxyPort, '/blocked', { host: 'limited.local' });
const secondBody = await readResponseBody(secondResponse);
releaseResponse?.();
const firstBody = await readResponseBody(firstResponse);
expect(firstResponse.statusCode).toEqual(200);
expect(firstBody).toEqual('limited released');
expect(secondResponse.statusCode).toEqual(429);
expect(secondBody.includes('fallback')).toBeFalse();
} finally {
releaseResponse?.();
firstResponse?.destroy();
secondResponse?.destroy();
await Promise.allSettled([
proxy.stop(),
closeServer(limitedBackend.server),
closeServer(fallbackBackend.server),
]);
}
});
export default tap.start();
+937
View File
@@ -0,0 +1,937 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
import { SourcePolicyCompiler, sourcePolicyLimits } from '../ts/config/classes.source-policy-compiler.js';
import type { ISourceProfile, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
function makeRoute(): IRouteConfig {
return {
id: 'route-1',
name: 'gitea',
priority: 10,
match: { ports: 443, domains: 'code.example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
};
}
function makeProfile(profile: Partial<ISourceProfile> & Pick<ISourceProfile, 'id' | 'name'>): ISourceProfile {
return {
description: '',
security: {},
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
...profile,
};
}
tap.test('source policy compiler expands one route into ordered source variants', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'ai',
name: 'AI Crawlers',
security: {
ipAllowList: ['203.0.113.0/24'],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const metadata: IRouteMetadata = {
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'ai' },
{ sourceProfileRef: 'public' },
],
};
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
expect(variants.length).toEqual(3);
expect(variants[0].name).toEqual('gitea:source:Trusted');
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(variants[0].security?.ipAllowList).toBeUndefined();
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(30);
expect(variants[2].match.clientIp).toBeUndefined();
expect(variants[2].security?.rateLimit?.maxRequests).toEqual(120);
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority! + 1);
});
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const metadata: IRouteMetadata = {
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
onExceeded: { type: '429', errorMessage: 'Slow down' },
},
],
};
const [variant] = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
expect(variant.security?.rateLimit?.maxRequests).toEqual(10);
expect(variant.security?.rateLimit?.errorMessage).toEqual('Slow down');
});
tap.test('source policy compiler forces source-policy rate limits to source IP keys', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'header',
headerName: 'x-forwarded-for',
},
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: {
enabled: true,
maxRequests: 10,
window: 60,
keyBy: 'header',
headerName: 'x-client-id',
},
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: ['/git'],
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
},
],
},
],
},
resolver,
'route-1',
);
expect(variants).toHaveLength(2);
expect(variants[0].security?.rateLimit?.keyBy).toEqual('ip');
expect(variants[0].security?.rateLimit?.headerName).toBeUndefined();
expect(variants[1].security?.rateLimit?.keyBy).toEqual('ip');
expect(variants[1].security?.rateLimit?.headerName).toBeUndefined();
});
tap.test('source policy binding can split Gitea path classes before its fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'ai',
name: 'AI Crawlers',
security: {
ipAllowList: ['203.0.113.0/24'],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'ai',
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: ['/*/*.git/info/refs'],
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
},
{
pathClass: 'normal-html',
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
},
],
},
{ sourceProfileRef: 'public' },
],
},
resolver,
'route-1',
);
expect(variants.length).toEqual(3);
expect(variants[0].name).toEqual('gitea:source:AI Crawlers:path:Git Smart HTTP');
expect(variants[0].match.clientIp).toEqual(['203.0.113.0/24']);
expect(variants[0].match.path).toEqual('/*/*.git/info/refs');
expect(variants[0].security?.rateLimit?.maxRequests).toEqual(600);
expect(variants[1].name).toEqual('gitea:source:AI Crawlers:path:Normal HTML');
expect(variants[1].match.path).toBeUndefined();
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(20);
expect(variants[2].name).toEqual('gitea:source:Public');
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
});
tap.test('source policy compiler uses built-in Gitea path class patterns', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }],
},
],
},
resolver,
'route-1',
);
expect(variants.map((variant) => variant.match.path)).toEqual([
'/*/*.git/info/refs',
'/*/*.git/git-upload-pack',
'/*/*.git/git-receive-pack',
'/*/*.git/info/lfs',
'/*/*.git/info/lfs/*',
undefined,
]);
expect(variants[0].id).toEqual('route-1:source:public:path:git-smart-http:1');
expect(variants[5].id).toEqual('route-1:source:public');
expect(variants[0].priority! > variants[5].priority!).toBeTrue();
});
tap.test('source policy compiler keeps path-specific variants above fallback variants', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [
{
pathClass: 'normal-html',
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
},
{
pathClass: 'git-smart-http',
pathPatterns: ['/*/*.git/info/refs'],
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
},
],
},
],
},
resolver,
'route-1',
);
const fallbackVariant = variants.find((variant) => variant.match.path === undefined)!;
const gitVariant = variants.find((variant) => variant.match.path === '/*/*.git/info/refs')!;
expect(gitVariant.priority! > fallbackVariant.priority!).toBeTrue();
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
});
tap.test('source policy compiler fails closed when wildcard binding shadows later bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{ sourceProfileRef: 'public' },
{ sourceProfileRef: 'trusted' },
],
},
resolver,
'route-1',
);
expect(variants).toEqual([]);
});
tap.test('source policy compiler adds terminal deny fallback for private-only bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [{ sourceProfileRef: 'trusted' }],
},
resolver,
'route-1',
);
expect(variants).toHaveLength(2);
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(variants[1].id).toEqual('route-1:source:deny-fallback');
expect(variants[1].match.clientIp).toBeUndefined();
expect(variants[1].action.type).toEqual('socket-handler');
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > makeRoute().priority!).toBeTrue();
});
tap.test('source policy compiler fails closed when expansion would exceed route variant caps', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const pathPolicies = Array.from({ length: sourcePolicyLimits.maxPathPoliciesPerBinding }, (_policy, policyIndex) => ({
pathClass: 'git-smart-http' as const,
pathPatterns: Array.from(
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy },
(_pattern, patternIndex) => `/heavy-${policyIndex}-${patternIndex}`,
),
}));
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'public', pathPolicies }],
};
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings)).toContain('compiled route variants');
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
});
tap.test('source policy compiler fails closed when configured bindings cannot compile', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: {
ipAllowList: [],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{ sourceProfileRef: 'empty-ai' },
],
},
resolver,
'route-1',
);
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
},
undefined,
'route-1',
);
expect(emptyProfileVariants.length).toEqual(0);
expect(missingResolverVariants.length).toEqual(0);
});
tap.test('source policy compiler keeps generated priorities inside SmartProxy bounds', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const route = makeRoute();
route.priority = 9000;
const variants = SourcePolicyCompiler.compileRoute(
route,
{
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
},
],
},
resolver,
'route-1',
);
expect(variants.length > 0).toBeTrue();
expect(variants.every((variant) => variant.priority! <= 10000 && variant.priority! >= 0)).toBeTrue();
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
});
tap.test('source policy compiler fails closed when route priority lacks variant headroom', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const route = makeRoute();
route.priority = 10000;
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'trusted' }],
};
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings, route)).toContain('priority headroom');
expect(SourcePolicyCompiler.compileRoute(route, metadata, resolver, 'route-1')).toEqual([]);
});
tap.test('RouteConfigManager applies source policy as expanded runtime routes', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'public' },
],
},
});
await manager.applyRoutes();
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0][0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(appliedRoutes[0][1].match.clientIp).toBeUndefined();
expect(appliedRoutes[0][1].security?.rateLimit?.maxRequests).toEqual(120);
});
tap.test('RouteConfigManager does not apply an uncompiled source-policy route', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: {
ipAllowList: [],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
},
});
await manager.applyRoutes();
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(0);
});
tap.test('RouteConfigManager fail-closes managed routes without source bindings', async () => {
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
ownerType: 'gatewayClient',
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
gatewayClientAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
},
});
await manager.applyRoutes();
expect(appliedRoutes).toHaveLength(1);
expect(appliedRoutes[0]).toHaveLength(0);
});
tap.test('RouteConfigManager rejects wildcard source policy bindings before later bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('Wildcard source profile bindings must be last');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
});
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain("Source profile 'missing' not found");
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
});
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: { ipAllowList: [] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain("Source profile 'Empty AI' has no source matches");
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
});
tap.test('RouteConfigManager accepts private-only source bindings without public fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).persistRoute = async () => undefined;
(manager as any).applyRoutes = async () => undefined;
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }],
},
});
expect(result.success).toBeTrue();
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
});
tap.test('RouteConfigManager rejects source policies with broad port range expansion', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
route: {
match: { ports: [{ from: 1, to: 1_000_000_000 }], domains: 'code.example.com' },
} as any,
});
expect(result.success).toBeFalse();
expect(result.message).toContain('compiled route-port variants');
expect(manager.getRoute('route-1')?.route.match.ports).toEqual(443);
});
tap.test('RouteConfigManager rejects negative source-policy maxConnections overrides', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('maxConnections');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].maxConnections).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: {
enabled: true,
maxRequests: 10,
window: 60,
keyBy: 'ip',
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
},
},
],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('rate limit error message');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].rateLimit).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: Array.from(
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
(_item, index) => `/too-many-${index}`,
),
},
],
},
],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('path patterns');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].pathPolicies).toBeUndefined();
});
export default tap.start();
+362 -1
View File
@@ -1,6 +1,8 @@
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' });
@@ -75,7 +77,7 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
},
} as any;
(dcRouter as any).routeConfigManager = {
setVpnClientIpsResolver: (resolver: unknown) => {
setVpnClientAccessResolver: (resolver: unknown) => {
resolverValues.push(resolver);
},
applyRoutes: async () => {
@@ -107,4 +109,363 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
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()
+200 -6
View File
@@ -21,7 +21,10 @@ const fireTypedRequest = async (
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeApiTokenManager = (scopes: TScope[]) => {
const makeApiTokenManager = (
scopes: TScope[],
policy?: interfaces.data.IApiTokenPolicy,
) => {
const token = {
id: 'token-1',
name: 'workhoster-test-token',
@@ -31,19 +34,33 @@ const makeApiTokenManager = (scopes: TScope[]) => {
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);
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
for (const policyScope of storedToken.policy?.scopes || []) {
scopes.add(policyScope);
}
const equivalentScopes: 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)));
return scopes.has(scope) || Boolean(equivalentScopes[scope]?.some((alias) => scopes.has(alias)));
},
};
};
@@ -91,6 +108,11 @@ const makeRouteConfigManager = () => {
if (!storedRoute) return { success: false, message: 'Route not found' };
if (patch.route) {
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
for (const [key, value] of Object.entries(patch.route)) {
if (value === null) {
delete (storedRoute.route as any)[key];
}
}
}
if (patch.enabled !== undefined) {
storedRoute.enabled = patch.enabled;
@@ -109,21 +131,41 @@ const makeRouteConfigManager = () => {
};
};
const standardSourceProfile: interfaces.data.ISourceProfile = {
id: 'standard',
name: 'STANDARD',
description: 'Standard test profile',
security: { ipAllowList: ['10.0.0.0/8'] },
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
};
const makeReferenceResolver = () => ({
listProfiles: () => [standardSourceProfile],
});
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 () => false,
exec: async () => Boolean(options.isAdmin),
},
},
dcRouterRef: {
options: {},
apiTokenManager: makeApiTokenManager(options.scopes),
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
referenceResolver: makeReferenceResolver(),
...options.dcRouterRef,
},
};
@@ -137,10 +179,12 @@ tap.test('WorkHosterHandler exposes capabilities and managed domains with workho
scopes: ['workhosters:read'],
dcRouterRef: {
options: {
remoteIngressConfig: { enabled: true },
dnsScopes: ['example.com'],
http3: { enabled: false },
},
remoteIngressManager: {
getHubSettings: () => ({ enabled: true }),
},
routeConfigManager: {
getMergedRoutes: () => ({ routes: [] }),
},
@@ -222,6 +266,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
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({
sourceBindings: [{ sourceProfileRef: 'standard', sourceProfileName: 'STANDARD' }],
ownerType: 'gatewayClient',
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
@@ -231,6 +276,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
workAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
});
createdRoute.route.security = { ipAllowList: ['*'] };
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
@@ -253,6 +299,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
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');
expect(routeConfig.routes.get('route-1')?.route.security).toBeUndefined();
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
@@ -274,6 +321,153 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
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({
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.27.1',
version: '14.0.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+3 -77
View File
@@ -1,14 +1,12 @@
import { logger } from '../logger.js';
import { AcmeConfigDoc } from '../db/documents/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
/**
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
*
* Lifecycle:
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
* - `start()` — loads the DB-backed singleton configuration.
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
*
@@ -20,32 +18,12 @@ import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
export class AcmeConfigManager {
private cached: IAcmeConfig | null = null;
constructor(private options: IDcRouterOptions) {}
public async start(): Promise<void> {
logger.log('info', 'AcmeConfigManager: starting');
let doc = await AcmeConfigDoc.load();
const doc = await AcmeConfigDoc.load();
if (!doc) {
// First-boot path: seed from legacy constructor fields if present.
const seed = this.deriveSeedFromOptions();
if (seed) {
doc = await this.createSeedDoc(seed);
logger.log(
'info',
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
);
} else {
logger.log(
'info',
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
);
}
} else if (this.deriveSeedFromOptions()) {
logger.log(
'warn',
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
);
logger.log('info', 'AcmeConfigManager: no AcmeConfig in DB — ACME disabled until configured via Domains > Certificates > Settings.');
}
this.cached = doc ? this.toPlain(doc) : null;
@@ -116,58 +94,6 @@ export class AcmeConfigManager {
// Internal helpers
// ==========================================================================
/**
* Build a seed object from the legacy constructor fields. Returns null
* if the user has not provided any of them.
*
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
* (full form). `smartProxyConfig.acme` wins when both are present.
*/
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
const acme = this.options.smartProxyConfig?.acme;
const tls = this.options.tls;
// Prefer the explicit smartProxyConfig.acme block if present.
if (acme?.accountEmail) {
return {
accountEmail: acme.accountEmail,
enabled: acme.enabled !== false,
useProduction: acme.useProduction !== false,
autoRenew: acme.autoRenew !== false,
renewThresholdDays: acme.renewThresholdDays ?? 30,
};
}
// Fall back to the short tls.contactEmail form.
if (tls?.contactEmail) {
return {
accountEmail: tls.contactEmail,
enabled: true,
useProduction: true,
autoRenew: true,
renewThresholdDays: 30,
};
}
return null;
}
private async createSeedDoc(
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
): Promise<AcmeConfigDoc> {
const doc = new AcmeConfigDoc();
doc.configId = 'acme-config';
doc.accountEmail = seed.accountEmail;
doc.enabled = seed.enabled;
doc.useProduction = seed.useProduction;
doc.autoRenew = seed.autoRenew;
doc.renewThresholdDays = seed.renewThresholdDays;
doc.updatedAt = Date.now();
doc.updatedBy = 'seed';
await doc.save();
return doc;
}
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
return {
accountEmail: doc.accountEmail,
+966 -226
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -111,13 +111,13 @@ export class ApiTokenManager {
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
if (scopes.has(scope)) return true;
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
const equivalentScopes: 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)));
return Boolean(equivalentScopes[scope]?.some((alias) => scopes.has(alias)));
}
/**
+28 -1
View File
@@ -68,11 +68,38 @@ export class DbSeeder {
}
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
{
name: 'TRUSTED NETWORKS',
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
security: {
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
maxConnections: 5000,
},
},
{
name: 'AI CRAWLERS',
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
security: {
ipAllowList: [],
rateLimit: {
enabled: true,
maxRequests: 30,
window: 60,
keyBy: 'ip',
},
},
},
{
name: 'PUBLIC',
description: 'Allow all traffic — no IP restrictions',
description: 'Public fallback source profile with per-IP request limiting',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'ip',
},
},
},
{
+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,
};
}
}
+87 -26
View File
@@ -7,6 +7,7 @@ import type {
IRouteMetadata,
IRoute,
IRouteSecurity,
IRouteSourceBinding,
} from '../../ts_interfaces/data/route-management.js';
const MAX_INHERITANCE_DEPTH = 5;
@@ -107,7 +108,7 @@ export class ReferenceResolver {
// If force-deleting with referencing routes, clear refs but keep resolved values
if (affectedIds.length > 0) {
await this.clearProfileRefsOnRoutes(affectedIds);
await this.clearProfileRefsOnRoutes(id, affectedIds, storedRoutes);
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
@@ -131,15 +132,22 @@ export class ReferenceResolver {
return [...this.profiles.values()];
}
public resolveSourceProfileSecurity(profileId: string): IRouteSecurity | null {
const resolvedSecurity = this.resolveSourceProfile(profileId);
return resolvedSecurity ? this.cloneSecurityFields(resolvedSecurity) : null;
}
public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) {
usage.set(profile.id, []);
}
for (const [routeId, stored] of storedRoutes) {
const ref = stored.metadata?.sourceProfileRef;
if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
const refs = this.getSourceProfileRefsFromMetadata(stored.metadata);
for (const ref of refs) {
if (usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
}
return usage;
@@ -151,7 +159,7 @@ export class ReferenceResolver {
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) {
if (this.metadataUsesSourceProfile(stored.metadata, profileId)) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
@@ -280,7 +288,8 @@ export class ReferenceResolver {
/**
* Resolve references for a single route.
* Materializes source profile and/or network target into the route's fields.
* Resolves source binding display names and/or network target references.
* Source profile security is resolved at apply time by SourcePolicyCompiler.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
@@ -289,19 +298,11 @@ export class ReferenceResolver {
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.sourceProfileRef) {
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
// Merge: profile provides base, route's inline values override
route = {
...route,
security: this.mergeSecurityFields(resolvedSecurity, route.security),
};
resolvedMetadata.sourceProfileName = profile?.name;
if (resolvedMetadata.sourceBindings?.length) {
const resolvedSourceBindings = this.resolveRouteSourceBindings(resolvedMetadata.sourceBindings);
if (resolvedSourceBindings) {
resolvedMetadata.sourceBindings = resolvedSourceBindings;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
}
}
@@ -336,7 +337,7 @@ export class ReferenceResolver {
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await RouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.filter((doc) => this.metadataUsesSourceProfile(doc.metadata, profileId))
.map((doc) => doc.id);
}
@@ -350,7 +351,7 @@ export class ReferenceResolver {
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) {
if (this.metadataUsesSourceProfile(stored.metadata, profileId)) {
ids.push(routeId);
}
}
@@ -371,6 +372,38 @@ export class ReferenceResolver {
// Private: source profile resolution with inheritance
// =========================================================================
private resolveRouteSourceBindings(sourceBindings: IRouteSourceBinding[]): IRouteSourceBinding[] | undefined {
const bindings = sourceBindings
.map((binding) => {
const profile = this.profiles.get(binding.sourceProfileRef);
if (!profile) {
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source binding resolution`);
return binding;
}
return {
...binding,
sourceProfileName: profile.name,
};
})
.filter((binding) => binding.sourceProfileRef);
return bindings.length > 0 ? bindings : undefined;
}
private metadataUsesSourceProfile(metadata: IRouteMetadata | undefined, profileId: string): boolean {
return this.getSourceProfileRefsFromMetadata(metadata).includes(profileId);
}
private getSourceProfileRefsFromMetadata(metadata: IRouteMetadata | undefined): string[] {
const refs = new Set<string>();
for (const binding of metadata?.sourceBindings || []) {
if (binding.sourceProfileRef) {
refs.add(binding.sourceProfileRef);
}
}
return [...refs];
}
private resolveSourceProfile(
profileId: string,
visited: Set<string> = new Set(),
@@ -445,10 +478,15 @@ export class ReferenceResolver {
if (override.authentication !== undefined) merged.authentication = override.authentication;
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
if (override.vpn !== undefined) merged.vpn = override.vpn;
return merged;
}
private cloneSecurityFields(security: IRouteSecurity): IRouteSecurity {
return structuredClone(security);
}
// =========================================================================
// Private: persistence
// =========================================================================
@@ -545,21 +583,44 @@ export class ReferenceResolver {
// Private: ref cleanup on force-delete
// =========================================================================
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
private async clearProfileRefsOnRoutes(
profileId: string,
routeIds: string[],
storedRoutes?: Map<string, IRoute>,
): Promise<void> {
for (const routeId of routeIds) {
const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
sourceProfileRef: undefined,
sourceProfileName: undefined,
};
doc.metadata = this.clearSourceProfileFromMetadata(doc.metadata, profileId);
doc.updatedAt = Date.now();
await doc.save();
}
const storedRoute = storedRoutes?.get(routeId);
if (storedRoute?.metadata) {
storedRoute.metadata = this.clearSourceProfileFromMetadata(storedRoute.metadata, profileId);
storedRoute.updatedAt = Date.now();
}
}
}
private clearSourceProfileFromMetadata(metadata: IRouteMetadata, profileId: string): IRouteMetadata {
const sourceBindings = metadata.sourceBindings?.length
? metadata.sourceBindings.filter((binding) => binding.sourceProfileRef !== profileId)
: undefined;
const nextMetadata: IRouteMetadata = {
...metadata,
sourceBindings: sourceBindings?.length ? sourceBindings : undefined,
};
if (!nextMetadata.sourceBindings && !nextMetadata.networkTargetRef) {
nextMetadata.lastResolvedAt = undefined;
}
return nextMetadata;
}
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await RouteDoc.findById(routeId);
+245 -31
View File
@@ -1,18 +1,24 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { RouteDoc } from '../db/index.js';
import { routePathClasses } from '../../ts_interfaces/data/route-management.js';
import type {
IHttpRedirectInfo,
IRoute,
IMergedRoute,
IRouteWarning,
IRouteMetadata,
IRoutePathPolicyBinding,
IRouteSourceBinding,
IRouteSecurity,
} from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
import { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
import { deriveHttpRedirects } from './helpers.http-redirects.js';
/** 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;
@@ -57,11 +63,12 @@ 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 | Promise<void>,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
private applyInboundProxyPolicies?: (routes: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
) {}
/** Expose routes map for reference resolution lookups. */
@@ -73,10 +80,14 @@ export class RouteConfigManager {
return this.routes.get(id);
}
public setVpnClientIpsResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
public setVpnClientAccessResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
): void {
this.getVpnClientIpsForRoute = resolver;
this.getVpnClientAccessForRoute = resolver;
}
public async runExclusiveRouteUpdate<T>(fn: () => Promise<T>): Promise<T> {
return await this.routeUpdateMutex.runExclusive(fn);
}
/**
@@ -120,6 +131,10 @@ export class RouteConfigManager {
return { routes: merged, warnings: [...this.warnings] };
}
public getHttpRedirects(): IHttpRedirectInfo[] {
return deriveHttpRedirects(this.getPreparedEnabledRoutesForApply());
}
// =========================================================================
// Route CRUD
// =========================================================================
@@ -132,6 +147,10 @@ export class RouteConfigManager {
): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(metadata?.sourceBindings);
if (sourceBindingsPayloadError) {
throw new Error(sourceBindingsPayloadError);
}
// Ensure route has a name
if (!route.name) {
@@ -145,6 +164,10 @@ export class RouteConfigManager {
route = resolved.route;
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
}
const sourceBindingsValidationError = this.validateSourceBindings(resolvedMetadata?.sourceBindings, route);
if (sourceBindingsValidationError) {
throw new Error(sourceBindingsValidationError);
}
const stored: IRoute = {
id,
@@ -175,6 +198,14 @@ export class RouteConfigManager {
if (!stored) {
return { success: false, message: 'Route not found' };
}
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(patch.metadata?.sourceBindings);
if (sourceBindingsPayloadError) {
return { success: false, message: sourceBindingsPayloadError };
}
const previousRoute = structuredClone(stored.route);
const previousMetadata = structuredClone(stored.metadata);
const previousEnabled = stored.enabled;
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
@@ -226,6 +257,14 @@ export class RouteConfigManager {
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
}
const sourceBindingsValidationError = this.validateSourceBindings(stored.metadata?.sourceBindings, stored.route);
if (sourceBindingsValidationError) {
stored.route = previousRoute;
stored.metadata = previousMetadata;
stored.enabled = previousEnabled;
return { success: false, message: sourceBindingsValidationError };
}
stored.updatedAt = Date.now();
await this.persistRoute(stored);
@@ -445,9 +484,8 @@ export class RouteConfigManager {
};
const normalized: IRouteMetadata = {
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
sourceBindings: this.normalizeSourceBindings(metadata.sourceBindings),
networkTargetRef: normalizeString(metadata.networkTargetRef),
sourceProfileName: normalizeString(metadata.sourceProfileName),
networkTargetName: normalizeString(metadata.networkTargetName),
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
? metadata.lastResolvedAt
@@ -468,13 +506,10 @@ export class RouteConfigManager {
externalKey: normalizeString(metadata.externalKey),
};
if (!normalized.sourceProfileRef) {
normalized.sourceProfileName = undefined;
}
if (!normalized.networkTargetRef) {
normalized.networkTargetName = undefined;
}
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
if (!normalized.sourceBindings && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
@@ -499,6 +534,127 @@ export class RouteConfigManager {
return normalized;
}
private normalizeSourceBindings(sourceBindings?: Partial<IRouteSourceBinding>[]): IRouteSourceBinding[] | undefined {
if (!Array.isArray(sourceBindings)) {
return undefined;
}
const normalizedBindings: IRouteSourceBinding[] = [];
for (const binding of sourceBindings) {
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
? binding.sourceProfileRef.trim()
: '';
if (!sourceProfileRef) {
continue;
}
const normalizedRateLimit = this.normalizeRateLimit(binding.rateLimit);
const normalizedPathPolicies = this.normalizePathPolicies(binding.pathPolicies);
normalizedBindings.push({
...(typeof binding.id === 'string' && binding.id.trim() ? { id: binding.id.trim() } : {}),
sourceProfileRef,
...(typeof binding.sourceProfileName === 'string' && binding.sourceProfileName.trim()
? { sourceProfileName: binding.sourceProfileName.trim() }
: {}),
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
...(typeof binding.maxConnections === 'number' && Number.isFinite(binding.maxConnections) && binding.maxConnections >= 0
? { maxConnections: binding.maxConnections }
: {}),
...(binding.onExceeded?.type === '429'
? {
onExceeded: {
type: '429' as const,
...(typeof binding.onExceeded.errorMessage === 'string' && binding.onExceeded.errorMessage.trim()
? { errorMessage: binding.onExceeded.errorMessage.trim() }
: {}),
},
}
: {}),
...(normalizedPathPolicies ? { pathPolicies: normalizedPathPolicies } : {}),
});
}
return normalizedBindings.length > 0 ? normalizedBindings : undefined;
}
private normalizePathPolicies(
pathPolicies?: IRoutePathPolicyBinding[],
): IRoutePathPolicyBinding[] | undefined {
if (!Array.isArray(pathPolicies)) {
return undefined;
}
const validClasses = new Set<string>(routePathClasses);
const normalizedPathPolicies: IRoutePathPolicyBinding[] = [];
for (const pathPolicy of pathPolicies) {
if (!validClasses.has(pathPolicy.pathClass)) {
continue;
}
const normalizedRateLimit = this.normalizeRateLimit(pathPolicy.rateLimit);
const pathPatterns = Array.isArray(pathPolicy.pathPatterns)
? [...new Set(pathPolicy.pathPatterns
.map((pattern) => typeof pattern === 'string' ? pattern.trim() : '')
.filter(Boolean))]
: undefined;
normalizedPathPolicies.push({
...(typeof pathPolicy.id === 'string' && pathPolicy.id.trim() ? { id: pathPolicy.id.trim() } : {}),
pathClass: pathPolicy.pathClass,
...(pathPatterns?.length ? { pathPatterns } : {}),
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
...(typeof pathPolicy.maxConnections === 'number' && Number.isFinite(pathPolicy.maxConnections) && pathPolicy.maxConnections >= 0
? { maxConnections: pathPolicy.maxConnections }
: {}),
...(pathPolicy.onExceeded?.type === '429'
? {
onExceeded: {
type: '429' as const,
...(typeof pathPolicy.onExceeded.errorMessage === 'string' && pathPolicy.onExceeded.errorMessage.trim()
? { errorMessage: pathPolicy.onExceeded.errorMessage.trim() }
: {}),
},
}
: {}),
});
}
return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined;
}
private validateSourceBindings(
sourceBindings: IRouteSourceBinding[] | undefined,
route: IDcRouterRouteConfig,
): string | undefined {
const shapeError = SourcePolicyCompiler.validateSourceBindingsShape(sourceBindings, route);
if (shapeError) {
return shapeError;
}
return SourcePolicyCompiler.validateResolvedSourceBindings(sourceBindings, this.referenceResolver);
}
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
if (!rateLimit || typeof rateLimit !== 'object') {
return undefined;
}
const maxRequests = Number(rateLimit.maxRequests);
const window = Number(rateLimit.window);
if (!Number.isFinite(maxRequests) || maxRequests < 0 || !Number.isFinite(window) || window < 0) {
return undefined;
}
return {
enabled: rateLimit.enabled !== false,
maxRequests,
window,
keyBy: 'ip',
...(typeof rateLimit.errorMessage === 'string' && rateLimit.errorMessage.trim()
? { errorMessage: rateLimit.errorMessage.trim() }
: {}),
};
}
// =========================================================================
// Private: warnings
// =========================================================================
@@ -559,19 +715,15 @@ export class RouteConfigManager {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
let enabledRoutes = this.getPreparedEnabledRoutesForApply();
// Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
enabledRoutes.push(this.prepareStoredRouteForApply(route));
}
}
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
for (const route of runtimeRoutes) {
enabledRoutes.push(this.prepareRouteForApply(route));
}
if (this.applyInboundProxyPolicies) {
enabledRoutes = this.applyInboundProxyPolicies(enabledRoutes);
}
await smartProxy.updateRoutes(enabledRoutes);
@@ -584,9 +736,43 @@ export class RouteConfigManager {
});
}
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
private getPreparedEnabledRoutesForApply(): plugins.smartproxy.IRouteConfig[] {
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Add all enabled routes with HTTP/3, VPN, and source-policy augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
}
}
return enabledRoutes;
}
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
if (this.isManagedAccessRoute(storedRoute) && !storedRoute.metadata?.sourceBindings?.length) {
return [];
}
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
const sourceBoundRoutes = SourcePolicyCompiler.compileRoute(
hydratedRoute || storedRoute.route,
storedRoute.metadata,
this.referenceResolver,
storedRoute.id,
);
return sourceBoundRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
}
private isManagedAccessRoute(storedRoute: IRoute): boolean {
const metadata = storedRoute.metadata;
if (storedRoute.origin !== 'api' || !metadata) {
return false;
}
return metadata.ownerType === 'gatewayClient'
|| metadata.ownerType === 'workhoster'
|| Boolean(metadata.gatewayClientId)
|| Boolean(metadata.workHosterId)
|| Boolean(metadata.externalKey);
}
private prepareRouteForApply(
@@ -607,20 +793,48 @@ export class RouteConfigManager {
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const vpnCallback = this.getVpnClientIpsForRoute;
if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
return route;
}
const existingVpnSecurity = route.security?.vpn || {};
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
existingVpnSecurity.allowedClients || [],
vpnEntries,
);
const vpnEntries = vpnCallback(dcRoute, routeId);
const existingEntries = route.security?.ipAllowList || [];
return {
...route,
security: {
...route.security,
ipAllowList: [...existingEntries, ...vpnEntries],
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;
}
}
+731
View File
@@ -0,0 +1,731 @@
import * as plugins from '../plugins.js';
import {
giteaRoutePathClassLabels,
giteaRoutePathClassPatterns,
routePathClasses,
} from '../../ts_interfaces/data/route-management.js';
import type {
IRoutePathPolicyBinding,
IRouteMetadata,
IRouteSecurity,
IRouteSourceBinding,
} from '../../ts_interfaces/data/route-management.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
const MIN_ROUTE_PRIORITY = 0;
const MAX_ROUTE_PRIORITY = 10000;
const SOURCE_PRIORITY_BAND = 0.0008;
const PATH_PRIORITY_BAND = 0.0001;
export const sourcePolicyLimits = {
maxBindings: 16,
maxPathPoliciesPerBinding: 12,
maxPathPatternsPerPolicy: 64,
maxPathPatternLength: 256,
maxPathPatternWildcards: 8,
maxSourceProfileRefLength: 256,
maxIdLength: 128,
maxExceededMessageLength: 512,
maxCompiledVariantsPerRoute: 512,
} as const;
export class SourcePolicyCompiler {
public static compileRoute(
route: plugins.smartproxy.IRouteConfig,
metadata: IRouteMetadata | undefined,
referenceResolver: ReferenceResolver | undefined,
routeId?: string,
): plugins.smartproxy.IRouteConfig[] {
const bindings = metadata?.sourceBindings || [];
if (bindings.length === 0) {
return [route];
}
if (this.validateSourceBindingsShape(bindings, route)) {
return [];
}
if (!referenceResolver) {
return [];
}
if (this.validateResolvedSourceBindings(bindings, referenceResolver)) {
return [];
}
const compiledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const basePriority = route.priority ?? 0;
let hasAllSourcesBinding = false;
bindings.forEach((binding, index) => {
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
if (!profile || !profileSecurity) {
return;
}
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
if (sourceMatches.length === 0) {
return;
}
if (this.matchesAllSources(sourceMatches)) {
hasAllSourcesBinding = true;
}
const sourcePriority = this.calculateSourcePriority(basePriority, index, bindings.length);
const sourceMatch = this.matchesAllSources(sourceMatches)
? { ...route.match }
: { ...route.match, clientIp: sourceMatches };
const pathPolicies = binding.pathPolicies || [];
if (pathPolicies.length === 0) {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
sourcePriority,
routeId,
sourceIndex: index,
}));
return;
}
let hasSourceFallback = false;
pathPolicies.forEach((pathPolicy, pathIndex) => {
const pathPatterns = this.getPathPatterns(pathPolicy);
if (pathPatterns.length === 0) {
hasSourceFallback = true;
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
pathPolicy,
sourcePriority,
routeId,
sourceIndex: index,
pathIndex,
pathPolicyCount: pathPolicies.length,
}));
return;
}
pathPatterns.forEach((pathPattern, pathPatternIndex) => {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
pathPolicy,
pathPattern,
sourcePriority,
routeId,
sourceIndex: index,
pathIndex,
pathPolicyCount: pathPolicies.length,
pathPatternIndex,
pathPatternCount: pathPatterns.length,
}));
});
});
if (!hasSourceFallback) {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
sourcePriority,
routeId,
sourceIndex: index,
}));
}
});
if (compiledRoutes.length > 0 && !hasAllSourcesBinding) {
compiledRoutes.push(this.buildDenyFallbackRoute(route, basePriority, routeId));
}
return this.applyIntegerPriorities(compiledRoutes, basePriority);
}
public static validateSourceBindingsPayload(sourceBindings?: Partial<IRouteSourceBinding>[]): string | undefined {
if (sourceBindings === undefined) {
return undefined;
}
if (!Array.isArray(sourceBindings)) {
return 'Source bindings must be an array';
}
if (sourceBindings.length === 0) {
return undefined;
}
if (sourceBindings.length > sourcePolicyLimits.maxBindings) {
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
}
const validClasses = new Set<string>(routePathClasses);
for (const binding of sourceBindings) {
if (!binding || typeof binding !== 'object') {
return 'Source binding must be an object';
}
if (typeof binding.sourceProfileRef !== 'string') {
return 'Source binding requires a source profile';
}
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
return `Source binding source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
}
if (binding.sourceProfileRef.trim().length === 0) {
return 'Source binding requires a source profile';
}
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
return `Source binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof binding.maxConnections === 'number' && binding.maxConnections < 0) {
return 'Source policy maxConnections must be non-negative';
}
const bindingRateLimitError = this.validateRateLimitPayload(binding.rateLimit);
if (bindingRateLimitError) {
return bindingRateLimitError;
}
const bindingMessage = binding.onExceeded?.errorMessage;
if (typeof bindingMessage === 'string' && bindingMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
const pathPolicies = binding.pathPolicies;
if (pathPolicies === undefined) {
continue;
}
if (!Array.isArray(pathPolicies)) {
return 'Source policy path policies must be an array';
}
if (pathPolicies.length > sourcePolicyLimits.maxPathPoliciesPerBinding) {
return `Source policy binding exceeds ${sourcePolicyLimits.maxPathPoliciesPerBinding} path policies`;
}
for (const pathPolicy of pathPolicies) {
if (!pathPolicy || typeof pathPolicy !== 'object') {
return 'Source policy path policy must be an object';
}
if (!validClasses.has(pathPolicy.pathClass)) {
return 'Source policy path policy uses an unsupported path class';
}
if (typeof pathPolicy.id === 'string' && pathPolicy.id.length > sourcePolicyLimits.maxIdLength) {
return `Source policy path policy id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof pathPolicy.maxConnections === 'number' && pathPolicy.maxConnections < 0) {
return 'Source policy path policy maxConnections must be non-negative';
}
const pathRateLimitError = this.validateRateLimitPayload(pathPolicy.rateLimit);
if (pathRateLimitError) {
return pathRateLimitError;
}
const pathMessage = pathPolicy.onExceeded?.errorMessage;
if (typeof pathMessage === 'string' && pathMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
const pathPatterns = pathPolicy.pathPatterns;
if (pathPatterns === undefined) {
continue;
}
if (!Array.isArray(pathPatterns)) {
return 'Source policy path patterns must be an array';
}
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
return `Source policy path class exceeds ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
}
for (const pattern of pathPatterns) {
if (typeof pattern !== 'string') {
return 'Source policy path pattern must be a string';
}
if (pattern.length > sourcePolicyLimits.maxPathPatternLength) {
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternLength} characters`;
}
const wildcardCount = pattern.split('*').length - 1;
if (wildcardCount > sourcePolicyLimits.maxPathPatternWildcards) {
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternWildcards} wildcards`;
}
}
}
}
return undefined;
}
private static validateRateLimitPayload(rateLimit: IRouteSecurity['rateLimit'] | undefined): string | undefined {
if (!rateLimit || typeof rateLimit !== 'object') {
return undefined;
}
const rawRateLimit = rateLimit as unknown as Record<string, unknown>;
for (const key of ['maxRequests', 'window'] as const) {
const value = rawRateLimit[key];
if (typeof value === 'string' && value.length > 32) {
return `Source policy rate limit ${key} exceeds 32 characters`;
}
}
if (
typeof rateLimit.errorMessage === 'string'
&& rateLimit.errorMessage.length > sourcePolicyLimits.maxExceededMessageLength
) {
return `Source policy rate limit error message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
return undefined;
}
public static validateSourcePolicyShape(
sourceBindings?: IRouteSourceBinding[],
route?: plugins.smartproxy.IRouteConfig,
): string | undefined {
return this.validateSourceBindingsShape(sourceBindings, route);
}
public static validateSourceBindingsShape(
sourceBindings?: IRouteSourceBinding[],
route?: plugins.smartproxy.IRouteConfig,
): string | undefined {
const payloadError = this.validateSourceBindingsPayload(sourceBindings);
if (payloadError) {
return payloadError;
}
const bindings = sourceBindings || [];
if (bindings.length === 0) {
return undefined;
}
let estimatedCompiledRoutes = 0;
for (const binding of bindings) {
const pathPolicies = binding.pathPolicies || [];
if (pathPolicies.length === 0) {
estimatedCompiledRoutes++;
} else {
let hasSourceFallback = false;
for (const pathPolicy of pathPolicies) {
const pathPatterns = this.getPathPatterns(pathPolicy);
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
return `Source policy path class expands beyond ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
}
if (pathPatterns.length === 0) {
hasSourceFallback = true;
estimatedCompiledRoutes++;
} else {
estimatedCompiledRoutes += pathPatterns.length;
}
}
if (!hasSourceFallback) {
estimatedCompiledRoutes++;
}
}
if (estimatedCompiledRoutes > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route variants`;
}
}
// Private-only source bindings add one terminal deny route to prevent fall-through
// to broader routes with the same host/path/port scope.
estimatedCompiledRoutes++;
const expandedPortCount = route ? this.getExpandedPortCount(route.match?.ports) : 1;
if (estimatedCompiledRoutes * expandedPortCount > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route-port variants`;
}
if (route && typeof route.priority === 'number' && Number.isFinite(route.priority)) {
const integerBasePriority = Math.trunc(this.clampPriority(route.priority));
if (integerBasePriority + estimatedCompiledRoutes > MAX_ROUTE_PRIORITY) {
return `Source policy route priority leaves no priority headroom for ${estimatedCompiledRoutes} compiled variants`;
}
}
return undefined;
}
public static validateResolvedSourcePolicy(
sourceBindings: IRouteSourceBinding[] | undefined,
referenceResolver: ReferenceResolver | undefined,
): string | undefined {
return this.validateResolvedSourceBindings(sourceBindings, referenceResolver);
}
public static validateResolvedSourceBindings(
sourceBindings: IRouteSourceBinding[] | undefined,
referenceResolver: ReferenceResolver | undefined,
): string | undefined {
const bindings = sourceBindings || [];
if (bindings.length === 0) {
return undefined;
}
if (!referenceResolver) {
return 'Source policy requires source profile resolution';
}
for (let index = 0; index < bindings.length; index++) {
const binding = bindings[index];
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
if (!profile) {
return `Source profile '${binding.sourceProfileRef}' not found`;
}
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
if (!profileSecurity) {
return `Source profile '${profile.name}' could not be resolved`;
}
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
if (sourceMatches.length === 0) {
return `Source profile '${profile.name}' has no source matches`;
}
const matchesAllSources = this.matchesAllSources(sourceMatches);
if (matchesAllSources && index < bindings.length - 1) {
return 'Wildcard source profile bindings must be last in source bindings';
}
}
return undefined;
}
private static buildCompiledRoute(options: {
route: plugins.smartproxy.IRouteConfig;
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
profileName: string;
profileSecurity: IRouteSecurity;
binding: IRouteSourceBinding;
pathPolicy?: IRoutePathPolicyBinding;
pathPattern?: string;
sourcePriority: number;
routeId?: string;
sourceIndex: number;
pathIndex?: number;
pathPolicyCount?: number;
pathPatternIndex?: number;
pathPatternCount?: number;
}): plugins.smartproxy.IRouteConfig {
const routeKey = options.route.id || options.routeId || options.route.name || 'route';
const bindingKey = options.binding.id || options.binding.sourceProfileRef || String(options.sourceIndex + 1);
const pathPolicyKey = options.pathPolicy
? options.pathPolicy.id || options.pathPolicy.pathClass
: undefined;
const pathLabel = options.pathPolicy
? giteaRoutePathClassLabels[options.pathPolicy.pathClass]
: undefined;
const pathPatternSuffix = options.pathPatternCount && options.pathPatternCount > 1
? `:${(options.pathPatternIndex || 0) + 1}`
: '';
const pathPriority = options.pathPolicy
? this.calculatePathPriorityOffset(
options.pathPattern,
options.pathIndex || 0,
options.pathPolicyCount || 1,
options.pathPatternIndex || 0,
options.pathPatternCount || 1,
)
: 0;
return {
...options.route,
id: pathPolicyKey
? `${routeKey}:source:${bindingKey}:path:${pathPolicyKey}${pathPatternSuffix}`
: `${routeKey}:source:${bindingKey}`,
name: pathLabel
? `${options.route.name || routeKey}:source:${options.profileName}:path:${pathLabel}${pathPatternSuffix}`
: `${options.route.name || routeKey}:source:${options.profileName}`,
match: options.pathPattern
? { ...options.sourceMatch, path: options.pathPattern }
: { ...options.sourceMatch },
priority: this.clampPriority(options.sourcePriority + pathPriority),
security: this.buildBindingSecurity(
options.route.security,
options.profileSecurity,
options.binding,
options.pathPolicy,
),
};
}
private static buildDenyFallbackRoute(
route: plugins.smartproxy.IRouteConfig,
basePriority: number,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const routeKey = route.id || routeId || route.name || 'route';
return {
...route,
id: `${routeKey}:source:deny-fallback`,
name: `${route.name || routeKey}:source:deny-fallback`,
match: { ...route.match },
priority: this.clampPriority(basePriority - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND),
action: {
type: 'socket-handler',
socketHandler: (socket) => this.denySocket(socket),
},
security: undefined,
};
}
private static denySocket(socket: plugins.net.Socket): void {
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', handleData);
socket.removeListener('error', cleanup);
socket.removeListener('close', cleanup);
};
const handleData = (chunk: string | Uint8Array) => {
cleanup();
if (this.looksLikeHttpRequest(chunk)) {
socket.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 9\r\nConnection: close\r\n\r\nForbidden');
return;
}
socket.destroy();
};
timeout = setTimeout(() => {
cleanup();
socket.destroy();
}, 2000) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
socket.once('data', handleData);
socket.once('error', cleanup);
socket.once('close', cleanup);
}
private static looksLikeHttpRequest(chunk: string | Uint8Array): boolean {
const prefix = typeof chunk === 'string'
? chunk.slice(0, 16)
: String.fromCharCode(...chunk.subarray(0, 16));
return /^(GET|POST|HEAD|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s/.test(prefix)
|| prefix.startsWith('PRI * HTTP/2.0');
}
private static getPathPatterns(pathPolicy: IRoutePathPolicyBinding): string[] {
const patterns: string[] = pathPolicy.pathPatterns?.length
? pathPolicy.pathPatterns
: giteaRoutePathClassPatterns[pathPolicy.pathClass];
return [...new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean))];
}
private static calculatePathPriorityOffset(
pathPattern: string | undefined,
pathIndex: number,
pathPolicyCount: number,
pathPatternIndex: number,
pathPatternCount: number,
): number {
if (!pathPattern) {
return 0;
}
const pathPolicyOffset = ((pathPolicyCount - pathIndex) / (pathPolicyCount + 1))
* (PATH_PRIORITY_BAND * 0.9);
const pathPatternOffset = ((pathPatternCount - pathPatternIndex) / (pathPatternCount + 1))
* (PATH_PRIORITY_BAND * 0.1 / (pathPolicyCount + 1));
return pathPolicyOffset + pathPatternOffset;
}
private static calculateSourcePriority(
basePriority: number,
sourceIndex: number,
sourceCount: number,
): number {
const safeBasePriority = this.clampPriority(
basePriority,
MIN_ROUTE_PRIORITY,
MAX_ROUTE_PRIORITY - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND,
);
const sourceStep = SOURCE_PRIORITY_BAND / (sourceCount + 1);
return safeBasePriority + ((sourceCount - sourceIndex) * sourceStep);
}
private static applyIntegerPriorities(
routes: plugins.smartproxy.IRouteConfig[],
basePriority: number,
): plugins.smartproxy.IRouteConfig[] {
if (routes.length === 0) {
return routes;
}
const priorityOrder = routes
.map((route, originalIndex) => ({
originalIndex,
priority: typeof route.priority === 'number' && Number.isFinite(route.priority)
? route.priority
: basePriority,
}))
.sort((a, b) => (b.priority - a.priority) || (a.originalIndex - b.originalIndex));
const topPriority = Math.trunc(this.clampPriority(
basePriority + routes.length,
MIN_ROUTE_PRIORITY + routes.length,
MAX_ROUTE_PRIORITY,
));
const integerPriorities = new Map<number, number>();
priorityOrder.forEach((entry, index) => {
integerPriorities.set(entry.originalIndex, topPriority - index);
});
return routes.map((route, index) => ({
...route,
priority: integerPriorities.get(index) ?? MIN_ROUTE_PRIORITY,
}));
}
private static clampPriority(
priority: number,
min = MIN_ROUTE_PRIORITY,
max = MAX_ROUTE_PRIORITY,
): number {
if (!Number.isFinite(priority)) {
return min;
}
return Math.min(max, Math.max(min, priority));
}
private static getExpandedPortCount(portRange: plugins.smartproxy.IRouteConfig['match']['ports'] | undefined): number {
if (portRange === undefined) {
return 1;
}
if (typeof portRange === 'number') {
return Number.isFinite(portRange) ? 1 : sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
if (!Array.isArray(portRange)) {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
let count = 0;
for (const portEntry of portRange) {
if (typeof portEntry === 'number') {
if (!Number.isFinite(portEntry)) {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
count++;
} else if (
portEntry
&& typeof portEntry === 'object'
&& Number.isFinite(portEntry.from)
&& Number.isFinite(portEntry.to)
&& portEntry.from <= portEntry.to
) {
count += Math.floor(portEntry.to) - Math.floor(portEntry.from) + 1;
} else {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
if (count > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return count;
}
}
return Math.max(1, count);
}
private static normalizeMaxConnections(value: IRouteSecurity['maxConnections']): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}
private static forceIpRateLimit(
rateLimit: IRouteSecurity['rateLimit'] | undefined,
): IRouteSecurity['rateLimit'] | undefined {
if (!rateLimit) {
return undefined;
}
const { headerName: _headerName, ...rest } = structuredClone(rateLimit as Record<string, any>);
return {
...rest,
keyBy: 'ip',
} as IRouteSecurity['rateLimit'];
}
private static sanitizeSourcePolicySecurity(security: IRouteSecurity): IRouteSecurity {
const sanitized = structuredClone(security);
const maxConnections = this.normalizeMaxConnections(sanitized.maxConnections);
if (maxConnections === undefined) {
delete sanitized.maxConnections;
} else {
sanitized.maxConnections = maxConnections;
}
if (sanitized.rateLimit) {
sanitized.rateLimit = this.forceIpRateLimit(sanitized.rateLimit);
}
return sanitized;
}
private static isEmptySecurity(security: IRouteSecurity): boolean {
return Object.keys(security).length === 0;
}
private static getSourceMatchEntries(security: IRouteSecurity): string[] {
const entries = security.ipAllowList || [];
const normalizedEntries: string[] = [];
for (const entry of entries) {
const rawEntry = typeof entry === 'string' ? entry : entry.ip;
if (typeof rawEntry !== 'string') continue;
const normalizedEntry = rawEntry.trim();
if (normalizedEntry) {
normalizedEntries.push(normalizedEntry);
}
}
return [...new Set(normalizedEntries)];
}
private static matchesAllSources(sourceMatches: string[]): boolean {
return sourceMatches.includes('*')
|| (sourceMatches.includes('0.0.0.0/0') && sourceMatches.includes('::/0'));
}
private static buildBindingSecurity(
routeSecurity: IRouteSecurity | undefined,
profileSecurity: IRouteSecurity,
binding: IRouteSourceBinding,
pathPolicy?: IRoutePathPolicyBinding,
): IRouteSecurity | undefined {
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
const sourceSecurity = this.omitSourceMatchFields(profileSecurity);
if (binding.rateLimit !== undefined) {
sourceSecurity.rateLimit = this.forceIpRateLimit(binding.rateLimit);
}
if (binding.maxConnections !== undefined) {
const maxConnections = this.normalizeMaxConnections(binding.maxConnections);
if (maxConnections === undefined) {
delete sourceSecurity.maxConnections;
} else {
sourceSecurity.maxConnections = maxConnections;
}
}
if (binding.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
sourceSecurity.rateLimit = {
...sourceSecurity.rateLimit,
errorMessage: binding.onExceeded.errorMessage,
};
}
if (pathPolicy?.rateLimit !== undefined) {
sourceSecurity.rateLimit = this.forceIpRateLimit(pathPolicy.rateLimit);
}
if (pathPolicy?.maxConnections !== undefined) {
const maxConnections = this.normalizeMaxConnections(pathPolicy.maxConnections);
if (maxConnections === undefined) {
delete sourceSecurity.maxConnections;
} else {
sourceSecurity.maxConnections = maxConnections;
}
}
if (pathPolicy?.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
sourceSecurity.rateLimit = {
...sourceSecurity.rateLimit,
errorMessage: pathPolicy.onExceeded.errorMessage,
};
}
const mergedSecurity = this.sanitizeSourcePolicySecurity({
...baseSecurity,
...sourceSecurity,
});
return this.isEmptySecurity(mergedSecurity) ? undefined : mergedSecurity;
}
private static omitSourceMatchFields(security: IRouteSecurity): IRouteSecurity {
const { ipAllowList: _ipAllowList, ...controls } = security;
return this.sanitizeSourcePolicySecurity(controls);
}
}
+57 -20
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:
@@ -35,6 +37,7 @@ export class TargetProfileManager {
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
createdBy: string;
}): Promise<string> {
// Enforce unique profile names
@@ -55,6 +58,7 @@ export class TargetProfileManager {
domains: data.domains,
targets: data.targets,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
@@ -88,6 +92,9 @@ export class TargetProfileManager {
if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
if (patch.allowRoutesByClientSourceIp !== undefined) {
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
}
profile.updatedAt = Date.now();
await this.persistProfile(profile);
@@ -199,29 +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[],
allRoutes: Map<string, IRoute> = new Map(),
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || [];
): 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
@@ -246,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] });
}
}
@@ -292,17 +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,
const dcRoute = route.route as IDcRouterRouteConfig;
const routeDomains = this.getRouteDomains(dcRoute);
const profileMatchesRoute = this.routeMatchesProfile(
dcRoute,
routeId,
profile,
routeNameIndex,
)) {
const routeDomains = (route.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
);
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(dcRoute);
if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
@@ -327,7 +345,7 @@ export class TargetProfileManager {
profile: ITargetProfile,
routeNameIndex: Map<string, string[]>,
): boolean {
const routeDomains: string[] = (route.match as any)?.domains || [];
const routeDomains = this.getRouteDomains(route);
const result = this.routeMatchesProfileDetailed(
route,
routeId,
@@ -425,6 +443,22 @@ 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');
@@ -500,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,
@@ -519,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 {
@@ -529,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;
+462
View File
@@ -0,0 +1,462 @@
import * as plugins from '../plugins.js';
import type { IHttpRedirectInfo } from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect';
const REDIRECT_STATUS_CODE = 301;
const REDIRECT_PRIORITY = 0;
const REDIRECT_TARGET_TEMPLATE = 'https://{domain}{path}';
const REDIRECT_INITIAL_DATA_TIMEOUT_MS = 10_000;
interface IRedirectCandidate {
key: string;
id: string;
domainPattern: string;
pathPattern?: string;
sourceRouteNames: Set<string>;
sourceRouteIds: Set<string>;
remoteIngress?: IRouteRemoteIngress;
}
interface IRedirectConflict {
routeName: string;
covers: boolean;
}
export interface IHttpRedirectDerivationResult {
redirects: IHttpRedirectInfo[];
runtimeRoutes: IDcRouterRouteConfig[];
}
export function deriveHttpRedirectConfiguration(
routes: plugins.smartproxy.IRouteConfig[],
): IHttpRedirectDerivationResult {
const candidates = collectRedirectCandidates(routes);
const httpRoutes = routes.filter((route) => isExplicitHttpRoute(route));
const redirects: IHttpRedirectInfo[] = [];
const runtimeRoutes: IDcRouterRouteConfig[] = [];
for (const candidate of candidates) {
const conflict = findHttpConflict(candidate, httpRoutes);
const redirectInfo: IHttpRedirectInfo = {
id: candidate.id,
status: conflict ? (conflict.covers ? 'covered' : 'skipped') : 'active',
domainPattern: candidate.domainPattern,
pathPattern: candidate.pathPattern,
fromTemplate: 'http://{domain}{path}',
toTemplate: REDIRECT_TARGET_TEMPLATE,
statusCode: REDIRECT_STATUS_CODE,
priority: REDIRECT_PRIORITY,
sourceRouteNames: [...candidate.sourceRouteNames].sort(),
sourceRouteIds: [...candidate.sourceRouteIds].sort(),
coveredByRouteNames: conflict ? [conflict.routeName] : [],
remoteIngress: Boolean(candidate.remoteIngress?.enabled),
notes: conflict
? conflict.covers
? 'An explicit HTTP route already covers this redirect scope.'
: 'Skipped because an explicit HTTP route overlaps this redirect scope.'
: undefined,
};
redirects.push(redirectInfo);
if (redirectInfo.status === 'active') {
runtimeRoutes.push(buildRuntimeRedirectRoute(candidate));
}
}
return { redirects, runtimeRoutes };
}
export function deriveHttpRedirects(
routes: plugins.smartproxy.IRouteConfig[],
): IHttpRedirectInfo[] {
return deriveHttpRedirectConfiguration(routes).redirects;
}
export function buildHttpRedirectRuntimeRoutes(
routes: plugins.smartproxy.IRouteConfig[],
): IDcRouterRouteConfig[] {
return deriveHttpRedirectConfiguration(routes).runtimeRoutes;
}
function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): IRedirectCandidate[] {
const candidates = new Map<string, IRedirectCandidate>();
for (const route of routes) {
if (!isHttpsRedirectSource(route)) {
continue;
}
for (const domainPattern of getDomainPatterns(route)) {
const key = createRedirectKey(domainPattern, route.match.path);
const existing = candidates.get(key);
if (existing) {
existing.sourceRouteNames.add(getRouteDisplayName(route));
if (route.id) existing.sourceRouteIds.add(route.id);
existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress);
continue;
}
const id = createRedirectRouteName(domainPattern, route.match.path);
candidates.set(key, {
key,
id,
domainPattern,
pathPattern: route.match.path,
sourceRouteNames: new Set([getRouteDisplayName(route)]),
sourceRouteIds: new Set(route.id ? [route.id] : []),
remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress),
});
}
}
return [...candidates.values()].sort((a, b) => a.id.localeCompare(b.id));
}
function isHttpsRedirectSource(route: plugins.smartproxy.IRouteConfig): boolean {
if (isGeneratedRedirectRoute(route)) return false;
if (route.enabled === false) return false;
if (route.action.type !== 'forward') return false;
if (!route.match.ports) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
if (!route.action.tls) return false;
if (!route.match.domains) return false;
if (route.match.transport === 'udp') return false;
if (route.match.protocol && route.match.protocol !== 'http') return false;
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) return false;
return true;
}
function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean {
if (isGeneratedRedirectRoute(route)) return false;
if (route.enabled === false) return false;
if (!route.match.ports) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 80)) return false;
if (route.match.transport === 'udp') return false;
return true;
}
function findHttpConflict(
candidate: IRedirectCandidate,
httpRoutes: plugins.smartproxy.IRouteConfig[],
): IRedirectConflict | undefined {
for (const route of httpRoutes) {
if (!httpRouteOverlapsCandidate(route, candidate)) {
continue;
}
return {
routeName: getRouteDisplayName(route),
covers: httpRouteCoversCandidate(route, candidate),
};
}
return undefined;
}
function httpRouteOverlapsCandidate(
route: plugins.smartproxy.IRouteConfig,
candidate: IRedirectCandidate,
): boolean {
return routeDomainOverlapsCandidate(route, candidate.domainPattern)
&& pathOverlaps(route.match.path, candidate.pathPattern);
}
function httpRouteCoversCandidate(
route: plugins.smartproxy.IRouteConfig,
candidate: IRedirectCandidate,
): boolean {
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) {
return false;
}
return routeDomainCoversCandidate(route, candidate.domainPattern)
&& pathCovers(route.match.path, candidate.pathPattern);
}
function routeDomainOverlapsCandidate(
route: plugins.smartproxy.IRouteConfig,
candidatePattern: string,
): boolean {
const routePatterns = getDomainPatterns(route);
if (routePatterns.length === 0) {
return true;
}
return routePatterns.some((pattern) => domainPatternsOverlap(pattern, candidatePattern));
}
function routeDomainCoversCandidate(
route: plugins.smartproxy.IRouteConfig,
candidatePattern: string,
): boolean {
const routePatterns = getDomainPatterns(route);
if (routePatterns.length === 0) {
return true;
}
return routePatterns.some((pattern) => domainPatternCovers(pattern, candidatePattern));
}
function getDomainPatterns(route: plugins.smartproxy.IRouteConfig): string[] {
if (!route.match.domains) return [];
return Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
}
function normalizePattern(pattern: string): string {
return pattern.trim().toLowerCase().replace(/\.$/, '');
}
function domainPatternCovers(coverPattern: string, candidatePattern: string): boolean {
const cover = normalizePattern(coverPattern);
const candidate = normalizePattern(candidatePattern);
if (cover === candidate) return true;
if (!candidate.includes('*')) return domainPatternMatchesHostname(cover, candidate);
const coverSuffix = getLeadingWildcardSuffix(cover);
const candidateSuffix = getLeadingWildcardSuffix(candidate);
if (coverSuffix && candidateSuffix) {
return candidateSuffix.endsWith(coverSuffix);
}
return false;
}
function domainPatternsOverlap(firstPattern: string, secondPattern: string): boolean {
const first = normalizePattern(firstPattern);
const second = normalizePattern(secondPattern);
if (first === second) return true;
if (!first.includes('*')) return domainPatternMatchesHostname(second, first);
if (!second.includes('*')) return domainPatternMatchesHostname(first, second);
const firstSuffix = getLeadingWildcardSuffix(first);
const secondSuffix = getLeadingWildcardSuffix(second);
if (firstSuffix && secondSuffix) {
return firstSuffix.endsWith(secondSuffix) || secondSuffix.endsWith(firstSuffix);
}
return false;
}
function domainPatternMatchesHostname(pattern: string, hostname: string): boolean {
const regex = wildcardPatternToRegex(normalizePattern(pattern));
return regex.test(normalizePattern(hostname));
}
function wildcardPatternToRegex(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`, 'i');
}
function getLeadingWildcardSuffix(pattern: string): string | undefined {
if (!pattern.startsWith('*')) return undefined;
if (pattern.slice(1).includes('*')) return undefined;
return pattern.slice(1);
}
function pathCovers(coverPath: string | undefined, candidatePath: string | undefined): boolean {
if (!coverPath) return true;
if (!candidatePath) return false;
if (coverPath === candidatePath) return true;
if (!coverPath.includes('*')) return false;
const coverPrefix = coverPath.split('*')[0];
if (!candidatePath.includes('*')) return candidatePath.startsWith(coverPrefix);
const candidatePrefix = candidatePath.split('*')[0];
return candidatePrefix.startsWith(coverPrefix);
}
function pathOverlaps(firstPath: string | undefined, secondPath: string | undefined): boolean {
if (!firstPath || !secondPath) return true;
if (firstPath === secondPath) return true;
const firstPrefix = firstPath.split('*')[0];
const secondPrefix = secondPath.split('*')[0];
return firstPrefix.startsWith(secondPrefix) || secondPrefix.startsWith(firstPrefix);
}
function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRouteConfig {
return {
id: candidate.id,
name: candidate.id,
description: 'Generated HTTP to HTTPS redirect',
priority: REDIRECT_PRIORITY,
tags: ['system', 'redirect', 'auto'],
match: {
ports: 80,
domains: candidate.domainPattern,
...(candidate.pathPattern ? { path: candidate.pathPattern } : {}),
},
action: {
type: 'socket-handler',
socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE),
},
...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}),
};
}
function mergeRemoteIngress(
current: IRouteRemoteIngress | undefined,
next: IRouteRemoteIngress | undefined,
): IRouteRemoteIngress | undefined {
if (!next?.enabled) return current;
if (!current?.enabled) {
return {
enabled: true,
...(next.edgeFilter?.length ? { edgeFilter: [...next.edgeFilter] } : {}),
};
}
const currentFilter = current.edgeFilter || [];
const nextFilter = next.edgeFilter || [];
if (currentFilter.length === 0 || nextFilter.length === 0) {
return { enabled: true };
}
return {
enabled: true,
edgeFilter: [...new Set([...currentFilter, ...nextFilter])].sort(),
};
}
function createRedirectKey(domainPattern: string, pathPattern?: string): string {
return `${normalizePattern(domainPattern)}|${pathPattern || ''}`;
}
function createRedirectRouteName(domainPattern: string, pathPattern?: string): string {
const key = createRedirectKey(domainPattern, pathPattern);
const slug = key
.replace(/\*/g, 'wildcard')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'route';
const hash = plugins.crypto.createHash('sha1').update(key).digest('hex').slice(0, 8);
return `${AUTO_REDIRECT_ROUTE_PREFIX}-${slug}-${hash}`;
}
function getRouteDisplayName(route: plugins.smartproxy.IRouteConfig): string {
return route.name || route.id || 'unnamed-route';
}
function isGeneratedRedirectRoute(route: plugins.smartproxy.IRouteConfig): boolean {
return Boolean(route.name?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX) || route.id?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX));
}
function createHttpRedirectHandler(
locationTemplate: string,
statusCode: number,
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
return (socket, context) => {
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', handleData);
socket.removeListener('error', cleanup);
socket.removeListener('close', cleanup);
};
const handleData = (data: string | Uint8Array) => {
cleanup();
const request = parseHttpRequest(data);
if (!request) {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
return;
}
const domain = normalizeHostHeader(request.headers.host) || context.domain || 'localhost';
const finalLocation = locationTemplate
.replace('{domain}', domain)
.replace('{port}', String(context.port))
.replace('{path}', request.path || '/')
.replace('{clientIp}', context.clientIp);
const message = `Redirecting to ${finalLocation}`;
const response = [
`HTTP/1.1 ${statusCode} ${getHttpStatusText(statusCode)}`,
`Location: ${finalLocation}`,
'Content-Type: text/plain',
`Content-Length: ${message.length}`,
'Connection: close',
'',
message,
].join('\r\n');
socket.end(response);
};
const timeout = setTimeout(() => {
cleanup();
socket.end('HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n');
}, REDIRECT_INITIAL_DATA_TIMEOUT_MS) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
socket.once('data', handleData);
socket.once('error', cleanup);
socket.once('close', cleanup);
};
}
function parseHttpRequest(data: string | Uint8Array): {
method: string;
path: string;
headers: Record<string, string>;
} | undefined {
const requestText = typeof data === 'string' ? data : new TextDecoder().decode(data);
const headerEnd = requestText.indexOf('\r\n\r\n');
const headerText = headerEnd >= 0 ? requestText.slice(0, headerEnd) : requestText;
const lines = headerText.split('\r\n');
const [method, rawPath] = (lines[0] || '').split(' ');
if (!method || !rawPath) return undefined;
const headers: Record<string, string> = {};
for (const line of lines.slice(1)) {
const colonIndex = line.indexOf(':');
if (colonIndex <= 0) continue;
const key = line.slice(0, colonIndex).trim().toLowerCase();
const value = line.slice(colonIndex + 1).trim();
headers[key] = value;
}
return {
method,
path: normalizeRequestPath(rawPath),
headers,
};
}
function normalizeRequestPath(rawPath: string): string {
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
try {
const url = new URL(rawPath);
return `${url.pathname}${url.search}` || '/';
} catch {
return '/';
}
}
return rawPath.startsWith('/') ? rawPath : '/';
}
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
if (!hostHeader) return undefined;
const host = hostHeader.split(',')[0].trim();
if (!host || /[\s\x00-\x1f\x7f]/.test(host)) return undefined;
if (host.startsWith('[')) {
const bracketIndex = host.indexOf(']');
return bracketIndex > 0 ? host.slice(0, bracketIndex + 1) : undefined;
}
return host.replace(/:(80|443)$/, '');
}
function getHttpStatusText(statusCode: number): string {
switch (statusCode) {
case 301:
return 'Moved Permanently';
case 302:
return 'Found';
case 307:
return 'Temporary Redirect';
case 308:
return 'Permanent Redirect';
default:
return 'Redirect';
}
}
+4 -1
View File
@@ -2,6 +2,9 @@
export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';
export { GatewayClientManager } from './classes.gateway-client-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js';
export { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
export * from './helpers.http-redirects.js';
export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
-111
View File
@@ -1,111 +0,0 @@
import * as plugins from '../plugins.js';
/**
* Base class for all cached documents with TTL support
*
* Extends smartdata's SmartDataDbDoc to add:
* - Automatic timestamps (createdAt, lastAccessedAt)
* - TTL/expiration support (expiresAt)
* - Helper methods for TTL management
*
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
* since decorators on abstract classes don't propagate correctly.
*/
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
/**
* Timestamp when the document was created
* NOTE: Subclasses must add @svDb() decorator
*/
public createdAt: Date = new Date();
/**
* Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator
*/
public expiresAt!: Date;
/**
* Timestamp of last access (for LRU-style eviction if needed)
* NOTE: Subclasses must add @svDb() decorator
*/
public lastAccessedAt: Date = new Date();
/**
* Set the TTL (time to live) for this document
* @param ttlMs Time to live in milliseconds
*/
public setTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set TTL using days
* @param days Number of days until expiration
*/
public setTTLDays(days: number): void {
this.setTTL(days * 24 * 60 * 60 * 1000);
}
/**
* Set TTL using hours
* @param hours Number of hours until expiration
*/
public setTTLHours(hours: number): void {
this.setTTL(hours * 60 * 60 * 1000);
}
/**
* Check if this document has expired
*/
public isExpired(): boolean {
if (!this.expiresAt) {
return false; // No expiration set
}
return new Date() > this.expiresAt;
}
/**
* Update the lastAccessedAt timestamp
*/
public touch(): void {
this.lastAccessedAt = new Date();
}
/**
* Get remaining TTL in milliseconds
* Returns 0 if expired, -1 if no expiration set
*/
public getRemainingTTL(): number {
if (!this.expiresAt) {
return -1;
}
const remaining = this.expiresAt.getTime() - Date.now();
return remaining > 0 ? remaining : 0;
}
/**
* Extend the TTL by the specified milliseconds from now
* @param ttlMs Additional time to live in milliseconds
*/
public extendTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set the document to never expire (100 years in the future)
*/
public setNeverExpires(): void {
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
}
}
/**
* TTL constants in milliseconds
*/
export const TTL = {
HOURS_1: 1 * 60 * 60 * 1000,
HOURS_24: 24 * 60 * 60 * 1000,
DAYS_7: 7 * 24 * 60 * 60 * 1000,
DAYS_30: 30 * 24 * 60 * 60 * 1000,
DAYS_90: 90 * 24 * 60 * 60 * 1000,
} as const;
+1 -3
View File
@@ -8,9 +8,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
* keyed on the fixed `configId = 'acme-config'` following the
* `VpnServerKeysDoc` pattern.
*
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
* constructor fields. Managed via the OpsServer UI at
* **Domains > Certificates > Settings**.
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
*/
@plugins.smartdata.Collection(() => getDb())
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
+3 -12
View File
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const TTL = plugins.smartdata.smartdataTtlValues;
/**
* Email status in the cache
*/
@@ -19,17 +20,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
* and maintaining email history for the configured TTL period.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<CachedEmail> {
/**
* Unique identifier for this email
*/
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const TTL = plugins.smartdata.smartdataTtlValues;
/**
* Helper to get the smartdata database instance
*/
@@ -29,17 +30,7 @@ export interface IIPReputationData {
* external API calls. Default TTL is 24 hours.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
export class CachedIPReputation extends plugins.smartdata.SmartdataCachedDocument<CachedIPReputation> {
/**
* IP address (unique identifier)
*/
@@ -0,0 +1,40 @@
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IEmailPortConfig } from '../../../ts_interfaces/data/email-settings.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class EmailServerSettingsDoc extends plugins.smartdata.SmartDataDbDoc<EmailServerSettingsDoc, EmailServerSettingsDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public settingsId: string = 'email-server-settings';
@plugins.smartdata.svDb()
public enabled: boolean = false;
@plugins.smartdata.svDb()
public emailConfig?: IUnifiedEmailServerOptions;
@plugins.smartdata.svDb()
public emailPortConfig?: IEmailPortConfig;
@plugins.smartdata.svDb()
public updatedAt: number = 0;
@plugins.smartdata.svDb()
public updatedBy: string = '';
constructor() {
super();
}
public static async load(): Promise<EmailServerSettingsDoc | null> {
return await EmailServerSettingsDoc.getInstance({ settingsId: 'email-server-settings' });
}
public static async findAll(): Promise<EmailServerSettingsDoc[]> {
return await EmailServerSettingsDoc.getInstances({});
}
}
@@ -0,0 +1,54 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IApiTokenPolicy, TGatewayClientType } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc<GatewayClientDoc, GatewayClientDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public type!: TGatewayClientType;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public hostnamePatterns: string[] = [];
@plugins.smartdata.svDb()
public allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']> = [];
@plugins.smartdata.svDb()
public capabilities: NonNullable<IApiTokenPolicy['capabilities']> = {};
@plugins.smartdata.svDb()
public enabled: boolean = true;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<GatewayClientDoc | null> {
return await GatewayClientDoc.getInstance({ id });
}
public static async findAll(): Promise<GatewayClientDoc[]> {
return await GatewayClientDoc.getInstances({});
}
}
@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRemoteIngressPerformanceConfig } from '../../../ts_interfaces/data/remoteingress.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@@ -27,6 +28,9 @@ export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<Remot
@plugins.smartdata.svDb()
public autoDerivePorts!: boolean;
@plugins.smartdata.svDb()
public performance?: IRemoteIngressPerformanceConfig;
@plugins.smartdata.svDb()
public tags!: string[];
@@ -0,0 +1,38 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRemoteIngressPerformanceConfig } from '../../../ts_interfaces/data/remoteingress.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RemoteIngressHubSettingsDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressHubSettingsDoc, RemoteIngressHubSettingsDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public settingsId: string = 'remote-ingress-hub-settings';
@plugins.smartdata.svDb()
public enabled?: boolean;
@plugins.smartdata.svDb()
public tunnelPort?: number;
@plugins.smartdata.svDb()
public hubDomain?: string;
@plugins.smartdata.svDb()
public performance?: IRemoteIngressPerformanceConfig;
@plugins.smartdata.svDb()
public updatedAt: number = 0;
@plugins.smartdata.svDb()
public updatedBy: string = '';
constructor() {
super();
}
public static async load(): Promise<RemoteIngressHubSettingsDoc | null> {
return await RemoteIngressHubSettingsDoc.getInstance({ settingsId: 'remote-ingress-hub-settings' });
}
}
@@ -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;
+3
View File
@@ -8,6 +8,7 @@ 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';
@@ -23,6 +24,7 @@ export * from './classes.cert-backoff.doc.js';
// Remote ingress document classes
export * from './classes.remote-ingress-edge.doc.js';
export * from './classes.remote-ingress-hub-settings.doc.js';
// RADIUS document classes
export * from './classes.vlan-mappings.doc.js';
@@ -38,3 +40,4 @@ export * from './classes.acme-config.doc.js';
// Email domain management
export * from './classes.email-domain.doc.js';
export * from './classes.email-server-settings.doc.js';
-3
View File
@@ -1,9 +1,6 @@
// Unified database manager
export * from './classes.dcrouter-db.js';
// TTL base class and constants
export * from './classes.cached.document.js';
// Cache cleaner
export * from './classes.cache.cleaner.js';
+25 -111
View File
@@ -24,7 +24,6 @@ import type {
*
* Responsibilities:
* - Load Domain/DnsRecord docs from the DB on start
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
* smartdns, provider domains hit the provider API)
@@ -53,13 +52,8 @@ export class DnsManager {
// Lifecycle
// ==========================================================================
/**
* Called from DcRouter after DcRouterDb is up. Performs first-boot seeding
* from legacy constructor config if (and only if) the DB is empty.
*/
public async start(): Promise<void> {
logger.log('info', 'DnsManager: starting');
await this.seedFromConstructorConfigIfEmpty();
}
public async stop(): Promise<void> {
@@ -77,103 +71,6 @@ export class DnsManager {
await this.applyDcrouterDomainsToDnsServer();
}
// ==========================================================================
// First-boot seeding
// ==========================================================================
/**
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
* seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with
* local (`record.source: 'local'`) records. On subsequent boots (DB has
* entries), constructor config is ignored with a warning.
*/
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
const existingDomains = await DomainDoc.findAll();
const hasLegacyConfig =
(this.options.dnsScopes && this.options.dnsScopes.length > 0) ||
(this.options.dnsRecords && this.options.dnsRecords.length > 0);
if (existingDomains.length > 0) {
if (hasLegacyConfig) {
logger.log(
'warn',
'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;
}
if (!hasLegacyConfig) {
return;
}
logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config');
const now = Date.now();
const seededDomains = new Map<string, DomainDoc>();
// Create one DomainDoc per dnsScope (these are the authoritative zones)
for (const scope of this.options.dnsScopes ?? []) {
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = scope.toLowerCase();
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'seed';
await domain.save();
seededDomains.set(domain.name, domain);
logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`);
}
// Map each legacy dnsRecord to its parent DomainDoc
for (const rec of this.options.dnsRecords ?? []) {
const parent = this.findParentDomain(rec.name, seededDomains);
if (!parent) {
logger.log(
'warn',
`DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`,
);
continue;
}
const record = new DnsRecordDoc();
record.id = plugins.uuid.v4();
record.domainId = parent.id;
record.name = rec.name.toLowerCase();
record.type = rec.type as TDnsRecordType;
record.value = rec.value;
record.ttl = rec.ttl ?? 300;
record.source = 'local';
record.createdAt = now;
record.updatedAt = now;
record.createdBy = 'seed';
await record.save();
}
logger.log(
'info',
`DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`,
);
}
private findParentDomain(
recordName: string,
domains: Map<string, DomainDoc>,
): DomainDoc | null {
const lower = recordName.toLowerCase().replace(/^\*\./, '');
let candidate: DomainDoc | null = null;
for (const [name, doc] of domains) {
if (lower === name || lower.endsWith(`.${name}`)) {
if (!candidate || name.length > candidate.name.length) {
candidate = doc;
}
}
}
return candidate;
}
// ==========================================================================
// DcRouter-hosted domain DnsServer wiring
// ==========================================================================
@@ -209,9 +106,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 +210,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 +261,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 +290,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}`);
}
+6 -2
View File
@@ -17,11 +17,15 @@ import { buildEmailDnsRecords } from './email-dns-records.js';
*/
export class EmailDomainManager {
private dcRouter: any; // DcRouter — avoids circular import
private readonly baseEmailDomains: IEmailDomainConfig[];
private baseEmailDomains: IEmailDomainConfig[] = [];
constructor(dcRouterRef: any) {
this.dcRouter = dcRouterRef;
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
this.setBaseEmailDomains(this.dcRouter.options?.emailConfig?.domains as IEmailDomainConfig[] | undefined);
}
public setBaseEmailDomains(domains: IEmailDomainConfig[] | undefined): void {
this.baseEmailDomains = (domains || [])
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
+221
View File
@@ -0,0 +1,221 @@
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
import { EmailServerSettingsDoc } from '../db/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type {
IEmailPortConfig,
IEmailServerSettings,
TEmailServerSettingsUpdate,
} from '../../ts_interfaces/data/email-settings.js';
const defaultEmailPorts = [25, 587, 465];
function clonePlain<T>(value: T | undefined): T | undefined {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value)) as T;
}
function hasOwn(objectArg: object, keyArg: string): boolean {
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
}
export class EmailSettingsManager {
private cachedEmailConfig?: IUnifiedEmailServerOptions;
private cachedEmailPortConfig?: IEmailPortConfig;
private enabled = false;
private updatedAt = 0;
private updatedBy = 'default';
constructor(private options: IDcRouterOptions) {}
public async start(): Promise<void> {
let doc = await EmailServerSettingsDoc.load();
if (!doc) {
doc = new EmailServerSettingsDoc();
doc.settingsId = 'email-server-settings';
doc.enabled = false;
doc.updatedAt = Date.now();
doc.updatedBy = 'default';
await doc.save();
}
this.loadFromDoc(doc);
this.applyToRuntimeOptions();
}
public async stop(): Promise<void> {
this.cachedEmailConfig = undefined;
this.cachedEmailPortConfig = undefined;
this.enabled = false;
}
public isEnabled(): boolean {
return this.enabled && Boolean(this.cachedEmailConfig);
}
public getEmailConfig(): IUnifiedEmailServerOptions | undefined {
return this.isEnabled() ? clonePlain(this.cachedEmailConfig) : undefined;
}
public getEmailPortConfig(): IEmailPortConfig | undefined {
return this.isEnabled() ? clonePlain(this.cachedEmailPortConfig) : undefined;
}
public getPublicSettings(): IEmailServerSettings {
const emailConfig = this.cachedEmailConfig;
const emailPortConfig = this.cachedEmailPortConfig;
return {
enabled: this.isEnabled(),
hostname: emailConfig?.hostname || null,
ports: [...(emailConfig?.ports || [])],
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
maxMessageSize: emailConfig?.maxMessageSize ?? null,
domainCount: emailConfig?.domains?.length || 0,
routeCount: emailConfig?.routes?.length || 0,
authUserCount: emailConfig?.auth?.users?.length || 0,
updatedAt: this.updatedAt,
updatedBy: this.updatedBy,
};
}
public async updateSettings(
updates: TEmailServerSettingsUpdate,
updatedBy: string,
): Promise<IEmailServerSettings> {
let doc = await EmailServerSettingsDoc.load();
if (!doc) {
doc = new EmailServerSettingsDoc();
doc.settingsId = 'email-server-settings';
}
const nextEnabled = hasOwn(updates, 'enabled') ? Boolean(updates.enabled) : doc.enabled;
const nextEmailConfig = this.patchEmailConfig(doc.emailConfig, updates, nextEnabled);
const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig, updates);
doc.enabled = nextEnabled;
doc.emailConfig = nextEmailConfig;
doc.emailPortConfig = nextEmailPortConfig;
doc.updatedAt = Date.now();
doc.updatedBy = updatedBy;
await doc.save();
this.loadFromDoc(doc);
this.applyToRuntimeOptions();
return this.getPublicSettings();
}
private loadFromDoc(doc: EmailServerSettingsDoc): void {
this.enabled = doc.enabled;
this.cachedEmailConfig = clonePlain(doc.emailConfig);
this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig);
this.updatedAt = doc.updatedAt;
this.updatedBy = doc.updatedBy;
}
private applyToRuntimeOptions(): void {
this.options.emailConfig = this.getEmailConfig();
this.options.emailPortConfig = this.getEmailPortConfig();
}
private patchEmailConfig(
existingConfig: IUnifiedEmailServerOptions | undefined,
updates: TEmailServerSettingsUpdate,
nextEnabled: boolean,
): IUnifiedEmailServerOptions | undefined {
const nextConfig: IUnifiedEmailServerOptions | undefined = clonePlain(existingConfig) || (nextEnabled ? {
hostname: 'localhost',
ports: [...defaultEmailPorts],
domains: [],
routes: [],
} : undefined);
if (!nextConfig) return undefined;
if (hasOwn(updates, 'hostname')) {
const hostname = updates.hostname?.trim() || '';
if (nextEnabled && !hostname) {
throw new Error('Email hostname is required when email is enabled');
}
nextConfig.hostname = hostname || nextConfig.hostname;
}
if (hasOwn(updates, 'ports')) {
nextConfig.ports = this.normalizePorts(updates.ports || []);
}
if (hasOwn(updates, 'maxMessageSize')) {
if (updates.maxMessageSize === null || updates.maxMessageSize === undefined) {
delete nextConfig.maxMessageSize;
} else {
const maxMessageSize = Number(updates.maxMessageSize);
if (!Number.isInteger(maxMessageSize) || maxMessageSize <= 0) {
throw new Error('maxMessageSize must be a positive integer');
}
nextConfig.maxMessageSize = maxMessageSize;
}
}
if (nextEnabled) {
if (!nextConfig.hostname?.trim()) {
throw new Error('Email hostname is required when email is enabled');
}
nextConfig.ports = this.normalizePorts(nextConfig.ports || []);
}
nextConfig.domains = nextConfig.domains || [];
nextConfig.routes = nextConfig.routes || [];
return nextConfig;
}
private patchEmailPortConfig(
existingPortConfig: IEmailPortConfig | undefined,
updates: TEmailServerSettingsUpdate,
): IEmailPortConfig | undefined {
const nextPortConfig: IEmailPortConfig = clonePlain(existingPortConfig) || {};
if (hasOwn(updates, 'portMapping')) {
if (updates.portMapping === null) {
delete nextPortConfig.portMapping;
} else {
nextPortConfig.portMapping = this.normalizePortMapping(updates.portMapping || {});
}
}
if (hasOwn(updates, 'receivedEmailsPath')) {
const receivedEmailsPath = updates.receivedEmailsPath?.trim() || '';
if (receivedEmailsPath) {
nextPortConfig.receivedEmailsPath = receivedEmailsPath;
} else {
delete nextPortConfig.receivedEmailsPath;
}
}
return Object.keys(nextPortConfig).length > 0 ? nextPortConfig : undefined;
}
private normalizePorts(ports: number[]): number[] {
const normalized = [...new Set(ports.map((port) => Number(port)))];
if (normalized.length === 0) {
throw new Error('At least one email port is required when email is enabled');
}
for (const port of normalized) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid email port: ${port}`);
}
}
return normalized.sort((a, b) => a - b);
}
private normalizePortMapping(portMapping: Record<number, number>): Record<number, number> {
const normalized: Record<number, number> = {};
for (const [externalPortString, internalPortValue] of Object.entries(portMapping)) {
const externalPort = Number(externalPortString);
const internalPort = Number(internalPortValue);
for (const port of [externalPort, internalPort]) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid email port mapping value: ${port}`);
}
}
normalized[externalPort] = internalPort;
}
return normalized;
}
}
+1
View File
@@ -1,4 +1,5 @@
export * from './classes.email-domain.manager.js';
export * from './classes.email-settings.manager.js';
export * from './classes.smartmta-storage-manager.js';
export * from './classes.workapp-mail-manager.js';
export * from './email-dns-records.js';
+2 -18
View File
@@ -1,4 +1,4 @@
import type * as plugins from '../plugins.js';
import * as plugins from '../plugins.js';
/**
* Configuration for HTTP/3 (QUIC) route augmentation.
@@ -36,22 +36,6 @@ export interface IHttp3Config {
};
}
type TPortRange = plugins.smartproxy.IRouteConfig['match']['ports'];
/**
* Check whether a TPortRange includes port 443.
*/
function portRangeIncludes443(ports: TPortRange): boolean {
if (typeof ports === 'number') return ports === 443;
if (Array.isArray(ports)) {
return ports.some((p) => {
if (typeof p === 'number') return p === 443;
return p.from <= 443 && p.to >= 443;
});
}
return false;
}
/**
* Check if a route name indicates an email route that should not get HTTP/3.
*/
@@ -85,7 +69,7 @@ export function routeQualifiesForHttp3(
if (route.action.type !== 'forward') return false;
// Must include port 443
if (!portRangeIncludes443(route.match.ports)) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
// Must have TLS
if (!route.action.tls) return false;
+24
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,29 @@ 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
DCROUTER_DNS_BIND_INTERFACE Override the embedded DNS UDP bind address
DATA_DIR=<path> Override the writable dcrouter data directory
`);
return;
}
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {
+126 -23
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;
@@ -142,8 +143,9 @@ export class MetricsManager {
public async getServerStats() {
return this.metricsCache.get('serverStats', async () => {
const smartMetricsData = await this.smartMetrics.getMetrics();
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
const smartProxy = this.dcRouter.smartProxy;
const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null;
const proxyStats = smartProxy ? await smartProxy.getStatistics() : null;
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
return {
@@ -290,27 +292,44 @@ export class MetricsManager {
});
}
public async getActiveConnectionSnapshots(
options: plugins.smartproxy.IActiveConnectionSnapshotOptions = {},
): Promise<plugins.smartproxy.IActiveConnectionSnapshot[]> {
const cacheKey = `activeConnectionSnapshots:${options.limit ?? 1000}:${options.routeId ?? ''}`;
return await this.metricsCache.get<plugins.smartproxy.IActiveConnectionSnapshot[]>(cacheKey, async () => {
if (!this.dcRouter.smartProxy) {
return [];
}
return this.dcRouter.smartProxy.getActiveConnectionSnapshots(options);
}, 500);
}
// Get connection info from SmartProxy
public async getConnectionInfo() {
return this.metricsCache.get('connectionInfo', () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
return this.metricsCache.get('connectionInfo', async () => {
const snapshots = await this.getActiveConnectionSnapshots({ limit: 10000 });
const connectionsByRoute = new Map<string, { count: number; lastActivity: Date }>();
for (const snapshot of snapshots) {
const source = snapshot.routeId || snapshot.domain || `${snapshot.protocol || 'connection'}:${snapshot.localPort}`;
const existing = connectionsByRoute.get(source) || { count: 0, lastActivity: new Date(snapshot.startedAtMs) };
existing.count++;
if (snapshot.startedAtMs > existing.lastActivity.getTime()) {
existing.lastActivity = new Date(snapshot.startedAtMs);
}
connectionsByRoute.set(source, existing);
}
const connectionsByRoute = proxyMetrics.connections.byRoute();
const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
for (const [routeName, count] of connectionsByRoute) {
for (const [source, info] of connectionsByRoute) {
connectionInfo.push({
type: 'https',
count,
source: routeName,
lastActivity: new Date(),
count: info.count,
source,
lastActivity: info.lastActivity,
});
}
return connectionInfo;
});
}
@@ -545,8 +564,9 @@ 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', () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
return this.metricsCache.get('networkStats', async () => {
const smartProxy = this.dcRouter.smartProxy;
const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return {
@@ -554,6 +574,7 @@ 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 }>(),
@@ -566,8 +587,22 @@ export class MetricsManager {
};
}
// Get metrics using the new API
const connectionsByIP = proxyMetrics.connections.byIP();
const activeConnectionSnapshots = await this.getActiveConnectionSnapshots({ limit: 10000 });
const connectionsByIP = new Map<string, number>();
const connectionsByRoute = new Map<string, number>();
const activeConnectionsByDomain = new Map<string, number>();
for (const snapshot of activeConnectionSnapshots) {
connectionsByIP.set(snapshot.sourceIp, (connectionsByIP.get(snapshot.sourceIp) || 0) + 1);
if (snapshot.routeId) {
connectionsByRoute.set(snapshot.routeId, (connectionsByRoute.get(snapshot.routeId) || 0) + 1);
}
if (snapshot.domain) {
activeConnectionsByDomain.set(snapshot.domain, (activeConnectionsByDomain.get(snapshot.domain) || 0) + 1);
}
}
const instantThroughput = proxyMetrics.throughput.instant();
// Get throughput rate
@@ -576,8 +611,11 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out
};
// Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10);
// Get top IPs by active connection count
const topIPs = Array.from(connectionsByIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([ip, count]) => ({ ip, count }));
// Get total data transferred
const totalDataTransferred = {
@@ -725,10 +763,17 @@ export class MetricsManager {
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
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();
// Aggregate per-IP domain request counts into per-domain totals
@@ -763,6 +808,9 @@ export class MetricsManager {
for (const entry of protocolCache) {
if (entry.domain) allKnownDomains.add(entry.domain);
}
for (const snapshot of activeConnectionSnapshots) {
if (snapshot.domain) allKnownDomains.add(snapshot.domain);
}
// Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>();
@@ -834,7 +882,7 @@ export class MetricsManager {
}
domainAgg.set(domain, {
activeConnections: Math.round(totalConns),
activeConnections: activeConnectionsByDomain.get(domain) ?? Math.round(totalConns),
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeKeys.length,
@@ -869,6 +917,7 @@ export class MetricsManager {
throughputRate,
topIPs,
topIPsByBandwidth,
topASNs,
totalDataTransferred,
throughputHistory,
throughputByIP,
@@ -882,6 +931,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 {
+8 -14
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;
@@ -24,6 +23,7 @@ export class OpsServer {
private statsHandler!: handlers.StatsHandler;
private radiusHandler!: handlers.RadiusHandler;
private emailOpsHandler!: handlers.EmailOpsHandler;
private emailSettingsHandler!: handlers.EmailSettingsHandler;
private certificateHandler!: handlers.CertificateHandler;
private remoteIngressHandler!: handlers.RemoteIngressHandler;
private routeManagementHandler!: handlers.RouteManagementHandler;
@@ -72,16 +72,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);
@@ -93,6 +83,7 @@ export class OpsServer {
this.statsHandler = new handlers.StatsHandler(this);
this.radiusHandler = new handlers.RadiusHandler(this);
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
this.emailSettingsHandler = new handlers.EmailSettingsHandler(this);
this.certificateHandler = new handlers.CertificateHandler(this);
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
@@ -113,6 +104,9 @@ export class OpsServer {
}
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();
+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 {
+395 -116
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();
@@ -61,54 +84,214 @@ export class AdminHandler {
}
/**
* 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) {
@@ -125,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,
};
@@ -139,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 };
}
)
);
@@ -198,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',
@@ -242,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',
};
}
}
+27 -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,7 @@ 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 };
@@ -38,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: [] };
@@ -52,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' };
@@ -67,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' };
@@ -85,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' };
+11 -70
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
@@ -37,29 +38,11 @@ export class CertificateHandler {
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 {
@@ -78,17 +61,6 @@ export class CertificateHandler {
)
);
// Legacy route-based reprovision (backward compat)
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)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
@@ -307,6 +279,11 @@ export class CertificateHandler {
}
}
if (backoffInfo && status !== 'valid' && status !== 'expiring') {
status = 'failed';
error = error || backoffInfo.lastError;
}
certificates.push({
domain,
routeNames: info.routeNames,
@@ -348,42 +325,6 @@ export class CertificateHandler {
return summary;
}
/**
* Legacy route-based reprovisioning. Kept for backward compatibility with
* older clients that send `reprovisionCertificate` typed-requests.
*
* Like reprovisionCertificateDomain, this triggers the full route apply
* pipeline rather than smartProxy.provisionCertificate(routeName) — which
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
*/
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) {
return { success: false, message: 'SmartProxy is not running' };
}
// Clear event-based status for domains in this route so the
// certificate-issued event can refresh them
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
try {
if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
}
}
/**
* Domain-based reprovisioning — clears backoff first, refreshes the smartacme
* cert (when forceRenew is set), then re-applies routes so the running Rust
+29 -32
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,
@@ -37,14 +39,7 @@ export class ConfigHandler {
? 'custom'
: 'filesystem';
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
let proxyIps = opts.proxyIps || [];
if (proxyIps.length === 0 && dcRouter.smartProxy) {
const spSettings = (dcRouter.smartProxy as any).settings;
if (spSettings?.proxyIPs?.length > 0) {
proxyIps = spSettings.proxyIPs;
}
}
const proxyIps = opts.proxyIps || [];
const system: interfaces.requests.IConfigData['system'] = {
baseDir: resolvedPaths.dcrouterHomeDir,
@@ -57,15 +52,15 @@ export class ConfigHandler {
};
// --- SmartProxy ---
const acmeConfig = dcRouter.acmeConfigManager?.getConfig();
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
if (opts.smartProxyConfig?.acme) {
const acme = opts.smartProxyConfig.acme;
if (acmeConfig) {
acmeInfo = {
enabled: acme.enabled !== false,
accountEmail: acme.accountEmail || '',
useProduction: acme.useProduction !== false,
autoRenew: acme.autoRenew !== false,
renewThresholdDays: acme.renewThresholdDays || 30,
enabled: acmeConfig.enabled,
accountEmail: acmeConfig.accountEmail,
useProduction: acmeConfig.useProduction,
autoRenew: acmeConfig.autoRenew,
renewThresholdDays: acmeConfig.renewThresholdDays,
};
}
@@ -98,21 +93,23 @@ export class ConfigHandler {
}
let portMapping: Record<string, number> | null = null;
if (opts.emailPortConfig?.portMapping) {
const emailSettings = dcRouter.emailSettingsManager?.getPublicSettings();
const rawPortMapping = emailSettings?.portMapping || opts.emailPortConfig?.portMapping;
if (rawPortMapping) {
portMapping = {};
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
for (const [ext, int] of Object.entries(rawPortMapping)) {
portMapping[String(ext)] = int as number;
}
}
const email: interfaces.requests.IConfigData['email'] = {
enabled: !!dcRouter.emailServer,
ports: opts.emailConfig?.ports || [],
enabled: emailSettings?.enabled ?? !!dcRouter.emailServer,
ports: emailSettings?.ports || opts.emailConfig?.ports || [],
portMapping,
hostname: opts.emailConfig?.hostname || null,
hostname: emailSettings?.hostname || opts.emailConfig?.hostname || null,
domains: emailDomains,
emailRouteCount: opts.emailConfig?.routes?.length || 0,
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
emailRouteCount: emailSettings?.routeCount ?? opts.emailConfig?.routes?.length ?? 0,
receivedEmailsPath: emailSettings?.receivedEmailsPath || opts.emailPortConfig?.receivedEmailsPath || null,
};
// --- DNS ---
@@ -123,8 +120,7 @@ export class ConfigHandler {
ttl: r.ttl,
}));
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB.
let dnsChallengeEnabled = false;
try {
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
@@ -146,12 +142,12 @@ export class ConfigHandler {
let tlsSource: 'acme' | 'static' | 'none' = 'none';
if (opts.tls?.certPath && opts.tls?.keyPath) {
tlsSource = 'static';
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
} else if (acmeConfig?.enabled) {
tlsSource = 'acme';
}
const tls: interfaces.requests.IConfigData['tls'] = {
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
contactEmail: acmeConfig?.accountEmail || null,
domain: opts.tls?.domain || null,
source: tlsSource,
certPath: opts.tls?.certPath || null,
@@ -184,16 +180,17 @@ export class ConfigHandler {
// --- Remote Ingress ---
const riCfg = opts.remoteIngressConfig;
const riSettings = dcRouter.remoteIngressManager?.getHubSettings();
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
tlsMode = 'custom';
} else if (riCfg?.hubDomain) {
} else if (riSettings?.hubDomain) {
try {
const { ProxyCertDoc } = await import('../../db/index.js');
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
const stored = await ProxyCertDoc.findByDomain(riSettings.hubDomain);
if (stored?.publicKey && stored?.privateKey) {
tlsMode = 'acme';
}
@@ -201,12 +198,12 @@ export class ConfigHandler {
}
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
enabled: !!dcRouter.remoteIngressManager,
tunnelPort: riCfg?.tunnelPort || null,
hubDomain: riCfg?.hubDomain || null,
enabled: !!riSettings?.enabled,
tunnelPort: riSettings?.tunnelPort || null,
hubDomain: riSettings?.hubDomain || null,
tlsMode,
connectedEdgeIps,
performance: riCfg?.performance,
performance: riSettings?.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() {
@@ -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,6 +45,10 @@ 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' };
@@ -0,0 +1,72 @@
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 EmailSettingsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailServerSettings>(
'getEmailServerSettings',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'email-domains:read' as any });
return { settings: this.getSettings() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailServerSettings>(
'updateEmailServerSettings',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'email-domains:write' as any,
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
if (!manager) {
return { success: false, message: 'EmailSettingsManager not initialized' };
}
try {
const settings = await this.opsServerRef.dcRouterRef.updateEmailServerSettings(
dataArg.settings,
auth.userId,
);
return { success: true, settings };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
private getSettings(): interfaces.data.IEmailServerSettings {
const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
if (manager) {
return manager.getPublicSettings();
}
const emailConfig = this.opsServerRef.dcRouterRef.options.emailConfig;
const emailPortConfig = this.opsServerRef.dcRouterRef.options.emailPortConfig;
return {
enabled: Boolean(emailConfig),
hostname: emailConfig?.hostname || null,
ports: [...(emailConfig?.ports || [])],
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
maxMessageSize: emailConfig?.maxMessageSize ?? null,
domainCount: emailConfig?.domains?.length || 0,
routeCount: emailConfig?.routes?.length || 0,
authUserCount: emailConfig?.auth?.users?.length || 0,
updatedAt: 0,
updatedBy: 'runtime-options',
};
}
}
+1
View File
@@ -5,6 +5,7 @@ export * from './security.handler.js';
export * from './stats.handler.js';
export * from './radius.handler.js';
export * from './email-ops.handler.js';
export * from './email-settings.handler.js';
export * from './certificate.handler.js';
export * from './remoteingress.handler.js';
export * from './route-management.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) {
+141 -73
View File
@@ -1,6 +1,11 @@
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';
function hasOwn(objectArg: object, keyArg: string): boolean {
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
}
export class RemoteIngressHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +23,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: [] };
@@ -46,29 +52,25 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
'createRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
try {
const edge = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges((manager) => manager.createEdge(
dataArg.name,
dataArg.listenPorts || [],
dataArg.tags,
dataArg.autoDerivePorts ?? true,
dataArg.performance,
));
return { success: true, edge };
} catch (err: unknown) {
return {
success: false,
edge: null as any,
};
}
const edge = await manager.createEdge(
dataArg.name,
dataArg.listenPorts || [],
dataArg.tags,
dataArg.autoDerivePorts ?? true,
);
// Sync allowed edges with the hub
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, edge };
},
),
);
@@ -78,21 +80,22 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
'deleteRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, message: 'RemoteIngress not configured' };
}
const deleted = await manager.deleteEdge(dataArg.id);
if (deleted && tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const deleted = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges(
(manager) => manager.deleteEdge(dataArg.id),
).catch((err: unknown) => {
if ((err as Error).message.includes('RemoteIngress')) {
return false;
}
throw err;
});
return {
success: deleted,
message: deleted ? undefined : 'Edge not found',
message: deleted ? undefined : 'Edge not found or RemoteIngress not configured',
};
},
),
@@ -103,41 +106,46 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
'updateRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, edge: null as any };
}
const edge = await manager.updateEdge(dataArg.id, {
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
enabled: dataArg.enabled,
tags: dataArg.tags,
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const result = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges(async (manager) => {
const edge = await manager.updateEdge(dataArg.id, {
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
enabled: dataArg.enabled,
performance: dataArg.performance,
tags: dataArg.tags,
});
if (!edge) {
return { success: false, edge: null as any };
}
if (!edge) {
return null;
}
// Sync allowed edges — ports, tags, or enabled may have changed
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
const breakdown = manager.getPortBreakdown(edge);
return {
success: true,
edge: {
const breakdown = manager.getPortBreakdown(edge);
return {
...edge,
secret: '********',
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(edge),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
},
};
}).catch((err: unknown) => {
if ((err as Error).message.includes('RemoteIngress')) {
return null;
}
throw err;
});
if (!result) {
return { success: false, edge: null as any };
}
return {
success: true,
edge: result,
};
},
),
@@ -148,23 +156,22 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'regenerateRemoteIngressSecret',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, secret: '' };
}
const secret = await manager.regenerateSecret(dataArg.id);
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const secret = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges(
(manager) => manager.regenerateSecret(dataArg.id),
).catch((err: unknown) => {
if ((err as Error).message.includes('RemoteIngress')) {
return null;
}
throw err;
});
if (!secret) {
return { success: false, secret: '' };
}
// Sync allowed edges since secret changed
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, secret };
},
),
@@ -175,6 +182,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: [] };
@@ -184,11 +192,71 @@ export class RemoteIngressHandler {
),
);
// Get hub-level settings (read)
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressHubSettings>(
'getRemoteIngressHubSettings',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
return {
settings: manager?.getHubSettings() || {
enabled: false,
tunnelPort: 8443,
updatedAt: 0,
updatedBy: 'default',
},
};
},
),
);
// Update hub-level settings (write)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngressHubSettings>(
'updateRemoteIngressHubSettings',
async (dataArg, toolsArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
try {
const updates: interfaces.data.TRemoteIngressHubSettingsUpdate = {};
if (hasOwn(dataArg, 'enabled') && dataArg.enabled !== undefined) {
updates.enabled = dataArg.enabled;
}
if (hasOwn(dataArg, 'tunnelPort') && dataArg.tunnelPort !== undefined) {
updates.tunnelPort = dataArg.tunnelPort;
}
if (hasOwn(dataArg, 'hubDomain')) {
updates.hubDomain = dataArg.hubDomain ?? null;
}
if (hasOwn(dataArg, 'performance')) {
updates.performance = dataArg.performance ?? null;
}
const settings = await this.opsServerRef.dcRouterRef.updateRemoteIngressHubSettings(
updates,
auth.userId,
);
return { success: true, settings };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Get a connection token for an edge (write — exposes secret)
adminRouter.addTypedHandler(
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' };
@@ -202,16 +270,16 @@ export class RemoteIngressHandler {
return { success: false, message: 'Edge is disabled' };
}
const hubHost = dataArg.hubHost
|| this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.hubDomain;
const hubSettings = manager.getHubSettings();
const hubHost = dataArg.hubHost || hubSettings.hubDomain;
if (!hubHost) {
return {
success: false,
message: 'No hub hostname configured. Set hubDomain in remoteIngressConfig or provide hubHost.',
message: 'No hub hostname configured. Set the RemoteIngress hub domain or provide hubHost.',
};
}
const hubPort = this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.tunnelPort ?? 8443;
const hubPort = hubSettings.tunnelPort;
const token = plugins.remoteingress.encodeConnectionToken({
hubHost,
@@ -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 {
@@ -61,6 +42,21 @@ export class RouteManagementHandler {
),
);
// Get generated HTTP redirects
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHttpRedirects>(
'getHttpRedirects',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:read');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { redirects: [] };
}
return { redirects: manager.getHttpRedirects() };
},
),
);
// Create route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
+98 -116
View File
@@ -1,7 +1,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 +17,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,18 +44,8 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
'getActiveConnections',
async (dataArg, toolsArg) => {
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
id: conn.id,
remoteAddress: conn.source.ip,
localAddress: conn.destination.ip,
startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type 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,
}));
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const connectionInfos = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
const summary = {
@@ -82,6 +73,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();
@@ -99,6 +91,7 @@ export class SecurityHandler {
throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
topASNs: networkStats.topASNs,
totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
@@ -117,6 +110,7 @@ export class SecurityHandler {
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
topIPsByBandwidth: [],
topASNs: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
@@ -136,6 +130,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,
@@ -161,7 +156,8 @@ export class SecurityHandler {
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityBlockRules>(
'listSecurityBlockRules',
async () => {
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { rules: manager ? await manager.listBlockRules() : [] };
},
@@ -171,9 +167,17 @@ export class SecurityHandler {
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListIpIntelligence>(
'listIpIntelligence',
async () => {
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { records: manager ? await manager.listIpIntelligence() : [] };
return {
records: manager
? await manager.listIpIntelligence({
ipAddresses: dataArg.ipAddresses,
limit: dataArg.limit,
})
: [],
};
},
),
);
@@ -181,7 +185,8 @@ export class SecurityHandler {
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCompiledSecurityPolicy>(
'getCompiledSecurityPolicy',
async () => {
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return {
policy: manager
@@ -196,6 +201,7 @@ export class SecurityHandler {
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) : [] };
},
@@ -208,6 +214,10 @@ export class SecurityHandler {
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({
@@ -216,7 +226,7 @@ export class SecurityHandler {
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
}, dataArg.identity.userId);
}, auth.userId);
return { success: true, rule };
},
),
@@ -226,6 +236,10 @@ export class SecurityHandler {
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, {
@@ -233,7 +247,7 @@ export class SecurityHandler {
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
}, dataArg.identity.userId);
}, auth.userId);
return rule ? { success: true, rule } : { success: false, message: 'Rule not found' };
},
),
@@ -243,9 +257,13 @@ export class SecurityHandler {
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, dataArg.identity.userId);
const success = await manager.deleteBlockRule(dataArg.id, auth.userId);
return { success, message: success ? undefined : 'Rule not found' };
},
),
@@ -255,6 +273,10 @@ export class SecurityHandler {
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);
@@ -328,106 +350,66 @@ export class SecurityHandler {
private async getActiveConnections(
protocol?: 'http' | 'https' | 'smtp' | 'smtps',
state?: string
): Promise<Array<{
id: string;
type: 'http' | 'smtp' | 'dns';
source: {
ip: string;
port: number;
country?: string;
};
destination: {
ip: string;
port: number;
service?: string;
};
startTime: number;
bytesTransferred: number;
status: 'active' | 'idle' | 'closing';
}>> {
const connections: Array<{
id: string;
type: 'http' | 'smtp' | 'dns';
source: {
ip: string;
port: number;
country?: string;
};
destination: {
ip: string;
port: number;
service?: string;
};
startTime: number;
bytesTransferred: number;
status: 'active' | 'idle' | 'closing';
}> = [];
// Get connection info and network stats from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
): Promise<interfaces.data.IConnectionInfo[]> {
const metricsManager = this.opsServerRef.dcRouterRef.metricsManager;
if (!metricsManager) {
return [];
}
for (const [ip, count] of networkStats.connectionsByIP) {
const tp = networkStats.throughputByIP?.get(ip);
connections.push({
id: `ip-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: 0,
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: 0,
bytesTransferred: count, // Store connection count here
status: 'active',
// Attach real throughput for the handler mapping
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} as any);
}
} else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available
connectionInfo.forEach((info, index) => {
connections.push({
id: `conn-${index}`,
type: 'http',
source: {
ip: 'unknown',
port: 0,
},
destination: {
ip: this.opsServerRef.dcRouterRef.options.publicIp || 'server',
port: 443,
service: info.source,
},
startTime: info.lastActivity.getTime(),
bytesTransferred: 0,
status: 'active',
});
});
const snapshots = await metricsManager.getActiveConnectionSnapshots({ limit: 10000 });
const connections = snapshots.map((snapshot): interfaces.data.IConnectionInfo => ({
id: String(snapshot.id),
remoteAddress: snapshot.sourcePort === null
? snapshot.sourceIp
: `${snapshot.sourceIp}:${snapshot.sourcePort}`,
localAddress: snapshot.targetHost
? `${snapshot.targetHost}:${snapshot.targetPort ?? snapshot.localPort}`
: `${this.opsServerRef.dcRouterRef.options.publicIp || 'server'}:${snapshot.localPort}`,
startTime: snapshot.startedAtMs,
protocol: this.mapSnapshotProtocol(snapshot),
state: this.mapSnapshotState(snapshot.state),
bytesReceived: snapshot.bytesIn,
bytesSent: snapshot.bytesOut,
}));
return connections.filter((connection) => {
if (protocol && connection.protocol !== protocol) {
return false;
}
if (state && connection.state !== state) {
return false;
}
return true;
});
}
private mapSnapshotProtocol(
snapshot: plugins.smartproxy.IActiveConnectionSnapshot,
): interfaces.data.IConnectionInfo['protocol'] {
if (snapshot.localPort === 465) {
return 'smtps';
}
// Filter by protocol if specified
if (protocol) {
return connections.filter(conn => {
if (protocol === 'https' || protocol === 'http') {
return conn.type === 'http';
}
return conn.type === protocol.replace('s', ''); // smtp/smtps -> smtp
});
if ([25, 587, 2525].includes(snapshot.localPort)) {
return 'smtp';
}
return connections;
switch (snapshot.protocol) {
case 'http':
return 'http';
case 'https':
case 'tls':
case 'tls-passthrough':
case 'tls-reencrypt':
case 'tls-socket-handler':
case 'quic':
return 'https';
default:
return snapshot.localPort === 80 ? 'http' : 'https';
}
}
private mapSnapshotState(state: string): interfaces.data.IConnectionInfo['state'] {
return state === 'closing' ? 'closing' : 'connected';
}
private async getRateLimitStatus(
@@ -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 {
+8
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,
@@ -327,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,
+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' };
+226 -43
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';
type TAuthContext = {
userId: string;
@@ -20,29 +21,23 @@ export class WorkHosterHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<TAuthContext> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return { userId: request.identity.userId, isAdmin: true };
} catch { /* fall through */ }
}
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return { userId: auth.userId, isAdmin: auth.isAdmin, token: auth.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 { userId: token.createdBy, isAdmin: token.policy?.role === 'admin', token };
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
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 {
@@ -56,6 +51,122 @@ export class WorkHosterHandler {
),
);
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',
@@ -171,7 +282,7 @@ export class WorkHosterHandler {
outbound: Boolean(dcRouter.emailServer),
},
remoteIngress: {
enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled),
enabled: Boolean(dcRouter.remoteIngressManager?.getHubSettings().enabled),
},
dns: {
authoritative: Boolean(dcRouter.options.dnsScopes?.length),
@@ -183,6 +294,30 @@ export class WorkHosterHandler {
};
}
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,
@@ -212,15 +347,38 @@ export class WorkHosterHandler {
return policyClient.id;
}
private assertGatewayClientOwnership(auth: TAuthContext, ownership: interfaces.data.IGatewayClientOwnership): void {
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 (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (ownership.gatewayClientType !== policy.gatewayClient.type || ownership.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
}
@@ -403,7 +561,8 @@ export class WorkHosterHandler {
enabled?: boolean,
deleteRoute?: boolean,
): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
this.assertGatewayClientOwnership(auth, ownership);
const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
this.assertGatewayClientOwnership(auth, resolvedOwnership);
this.assertRouteTargetsAllowed(auth, route);
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
@@ -411,7 +570,7 @@ export class WorkHosterHandler {
return { success: false, message: 'Route management not initialized' };
}
const externalKey = this.buildGatewayClientExternalKey(ownership);
const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
if (deleteRoute) {
@@ -428,21 +587,29 @@ export class WorkHosterHandler {
return { success: false, message: 'route is required unless delete=true' };
}
const sourceBindings = this.getManagedRouteSourceBindings();
if (!sourceBindings) {
return { success: false, message: 'STANDARD source profile not found' };
}
const metadata: interfaces.data.IRouteMetadata = {
sourceBindings,
ownerType: 'gatewayClient',
gatewayClientType: ownership.gatewayClientType,
gatewayClientId: ownership.gatewayClientId,
gatewayClientAppId: ownership.appId,
workHosterType: ownership.gatewayClientType,
workHosterId: ownership.gatewayClientId,
workAppId: ownership.appId,
gatewayClientType: resolvedOwnership.gatewayClientType,
gatewayClientId: resolvedOwnership.gatewayClientId,
gatewayClientAppId: resolvedOwnership.appId,
workHosterType: resolvedOwnership.gatewayClientType,
workHosterId: resolvedOwnership.gatewayClientId,
workAppId: resolvedOwnership.appId,
externalKey,
};
const normalizedRoute = this.normalizeGatewayClientRoute(route, ownership, externalKey);
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
if (existingRoute) {
const routePatch: Partial<interfaces.data.IDcRouterRouteConfig> = { ...normalizedRoute };
(routePatch as any).security = null;
const result = await manager.updateRoute(existingRoute.id, {
route: normalizedRoute,
route: routePatch,
enabled: enabled ?? true,
metadata,
});
@@ -455,7 +622,7 @@ export class WorkHosterHandler {
return { success: true, action: 'created', routeId };
}
private buildGatewayClientExternalKey(ownership: interfaces.data.IGatewayClientOwnership): string {
private buildGatewayClientExternalKey(ownership: Required<interfaces.data.IGatewayClientOwnership>): string {
return [
ownership.gatewayClientType,
ownership.gatewayClientId,
@@ -478,13 +645,29 @@ export class WorkHosterHandler {
private normalizeGatewayClientRoute(
route: interfaces.data.IDcRouterRouteConfig,
ownership: interfaces.data.IGatewayClientOwnership,
ownership: Required<interfaces.data.IGatewayClientOwnership>,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };
const normalizedRoute = structuredClone(route);
delete normalizedRoute.security;
if (!normalizedRoute.name) {
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
}
return normalizedRoute;
}
private getManagedRouteSourceBindings(): interfaces.data.IRouteSourceBinding[] | undefined {
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const standardProfile = resolver?.listProfiles().find((profile: interfaces.data.ISourceProfile) => {
return profile.id.trim().toLowerCase() === 'standard'
|| profile.name.trim().toLowerCase() === 'standard';
});
if (!standardProfile) {
return undefined;
}
return [{
sourceProfileRef: standardProfile.id,
sourceProfileName: standardProfile.name,
}];
}
}
+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';
+54 -80
View File
@@ -91,7 +91,6 @@ export class RadiusServer {
private vlanManager: VlanManager;
private accountingManager: AccountingManager;
private config: IRadiusServerConfig;
private clientSecrets: Map<string, string> = new Map();
private running: boolean = false;
// Statistics
@@ -138,24 +137,18 @@ export class RadiusServer {
await this.vlanManager.importMappings(this.config.vlanAssignment.mappings);
}
// Build client secrets map
this.buildClientSecretsMap();
const cidrSecrets = this.buildClientSecretsMap();
// Create the RADIUS server
this.radiusServer = new plugins.smartradius.RadiusServer({
authPort: this.config.authPort,
acctPort: this.config.acctPort,
bindAddress: this.config.bindAddress,
defaultSecret: this.getDefaultSecret(),
cidrSecrets,
authenticationHandler: this.handleAuthentication.bind(this),
accountingHandler: this.handleAccounting.bind(this),
});
// Configure per-client secrets
for (const [ip, secret] of this.clientSecrets) {
this.radiusServer.setClientSecret(ip, secret);
}
// Start the server
await this.radiusServer.start();
@@ -189,19 +182,22 @@ export class RadiusServer {
/**
* Handle authentication request
*/
private async handleAuthentication(request: any): Promise<any> {
private async handleAuthentication(
request: plugins.smartradius.IAuthenticationRequest,
): Promise<plugins.smartradius.IAuthenticationResponse> {
this.stats.authRequests++;
const authData: IAuthRequestData = {
username: request.attributes?.UserName || '',
password: request.attributes?.UserPassword,
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
nasPort: request.attributes?.NasPort,
nasPortType: request.attributes?.NasPortType,
nasIdentifier: request.attributes?.NasIdentifier,
calledStationId: request.attributes?.CalledStationId,
callingStationId: request.attributes?.CallingStationId,
serviceType: request.attributes?.ServiceType,
username: request.username || '',
password: request.password,
nasIpAddress: request.nasIpAddress || request.clientAddress || '',
nasPort: request.nasPort,
nasPortType: request.nasPortType !== undefined ? String(request.nasPortType) : undefined,
nasIdentifier: request.nasIdentifier,
calledStationId: request.calledStationId,
callingStationId: request.callingStationId,
serviceType: request.serviceType !== undefined ? String(request.serviceType) : undefined,
framedMtu: request.framedMtu,
};
logger.log('debug', `RADIUS Auth Request: user=${authData.username}, NAS=${authData.nasIpAddress}`);
@@ -215,15 +211,15 @@ export class RadiusServer {
logger.log('info', `RADIUS Auth Accept: user=${authData.username}, VLAN=${result.vlanId}`);
// Build response with VLAN attributes
const response: any = {
const response: plugins.smartradius.IAuthenticationResponse = {
code: plugins.smartradius.ERadiusCode.AccessAccept,
replyMessage: result.replyMessage,
};
// Add VLAN attributes if assigned
if (result.vlanId !== undefined) {
response.tunnelType = 13; // VLAN
response.tunnelMediumType = 6; // IEEE 802
response.tunnelType = plugins.smartradius.ETunnelType.Vlan;
response.tunnelMediumType = plugins.smartradius.ETunnelMediumType.Ieee802;
response.tunnelPrivateGroupId = String(result.vlanId);
}
@@ -257,34 +253,37 @@ export class RadiusServer {
/**
* Handle accounting request
*/
private async handleAccounting(request: any): Promise<any> {
private async handleAccounting(
request: plugins.smartradius.IAccountingRequest,
): Promise<plugins.smartradius.IAccountingResponse> {
this.stats.accountingRequests++;
if (!this.config.accounting?.enabled) {
// Still respond even if not tracking
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
return { success: true };
}
const statusType = request.attributes?.AcctStatusType;
const sessionId = request.attributes?.AcctSessionId || '';
const statusType = request.statusType;
const sessionId = request.sessionId || '';
const accountingData = {
sessionId,
username: request.attributes?.UserName || '',
macAddress: request.attributes?.CallingStationId,
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
nasPort: request.attributes?.NasPort,
nasPortType: request.attributes?.NasPortType,
nasIdentifier: request.attributes?.NasIdentifier,
calledStationId: request.attributes?.CalledStationId,
callingStationId: request.attributes?.CallingStationId,
inputOctets: request.attributes?.AcctInputOctets,
outputOctets: request.attributes?.AcctOutputOctets,
inputPackets: request.attributes?.AcctInputPackets,
outputPackets: request.attributes?.AcctOutputPackets,
sessionTime: request.attributes?.AcctSessionTime,
terminateCause: request.attributes?.AcctTerminateCause,
serviceType: request.attributes?.ServiceType,
username: request.username || '',
macAddress: request.callingStationId,
nasIpAddress: request.nasIpAddress || request.clientAddress || '',
nasPort: request.nasPort,
nasPortType: request.nasPortType !== undefined ? String(request.nasPortType) : undefined,
nasIdentifier: request.nasIdentifier,
calledStationId: request.calledStationId,
callingStationId: request.callingStationId,
inputOctets: request.inputOctets,
outputOctets: request.outputOctets,
inputPackets: request.inputPackets,
outputPackets: request.outputPackets,
sessionTime: request.sessionTime,
terminateCause: request.terminateCause !== undefined ? String(request.terminateCause) : undefined,
framedIpAddress: request.framedIpAddress,
serviceType: request.serviceType !== undefined ? String(request.serviceType) : undefined,
};
try {
@@ -311,7 +310,7 @@ export class RadiusServer {
logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
}
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
return { success: true };
}
/**
@@ -391,37 +390,18 @@ export class RadiusServer {
/**
* Build client secrets map from configuration
*/
private buildClientSecretsMap(): void {
this.clientSecrets.clear();
private buildClientSecretsMap(): Record<string, string> {
const cidrSecrets: Record<string, string> = {};
for (const client of this.config.clients) {
if (!client.enabled) {
continue;
}
// Handle CIDR ranges
if (client.ipRange.includes('/')) {
// For CIDR ranges, we'll use the network address as key
// In practice, smartradius may handle this differently
const [network] = client.ipRange.split('/');
this.clientSecrets.set(network, client.secret);
} else {
this.clientSecrets.set(client.ipRange, client.secret);
}
cidrSecrets[client.ipRange] = client.secret;
}
}
/**
* Get default secret for unknown clients
*/
private getDefaultSecret(): string {
// Use first enabled client's secret as default, or a random one
for (const client of this.config.clients) {
if (client.enabled) {
return client.secret;
}
}
return plugins.crypto.randomBytes(16).toString('hex');
return cidrSecrets;
}
/**
@@ -430,21 +410,19 @@ export class RadiusServer {
async addClient(client: IRadiusClient): Promise<void> {
// Check if client already exists
const existingIndex = this.config.clients.findIndex(c => c.name === client.name);
const previousClient = existingIndex >= 0 ? this.config.clients[existingIndex] : undefined;
if (existingIndex >= 0) {
this.config.clients[existingIndex] = client;
} else {
this.config.clients.push(client);
}
// Update client secrets if running
if (this.running && this.radiusServer && client.enabled) {
if (client.ipRange.includes('/')) {
const [network] = client.ipRange.split('/');
this.radiusServer.setClientSecret(network, client.secret);
this.clientSecrets.set(network, client.secret);
} else {
this.radiusServer.setClientSecret(client.ipRange, client.secret);
this.clientSecrets.set(client.ipRange, client.secret);
if (this.running && this.radiusServer) {
if (previousClient) {
this.radiusServer.removeNetworkSecret(previousClient.ipRange);
}
if (client.enabled) {
this.radiusServer.setNetworkSecret(client.ipRange, client.secret);
}
}
@@ -460,12 +438,8 @@ export class RadiusServer {
const client = this.config.clients[index];
this.config.clients.splice(index, 1);
// Remove from secrets map
if (client.ipRange.includes('/')) {
const [network] = client.ipRange.split('/');
this.clientSecrets.delete(network);
} else {
this.clientSecrets.delete(client.ipRange);
if (this.radiusServer) {
this.radiusServer.removeNetworkSecret(client.ipRange);
}
logger.log('info', `RADIUS client removed: ${name}`);
+2 -2
View File
@@ -66,7 +66,7 @@ await router.start();
- 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.
- The published package exposes the `dcrouter` npm bin through `./cli.js`; `runCli()` is the supported code-level bootstrap entrypoint.
## Use Another Module When...
@@ -91,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.
+231 -24
View File
@@ -1,29 +1,43 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc } from '../db/index.js';
import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc, RemoteIngressHubSettingsDoc } from '../db/index.js';
interface IRemoteIngressFirewallConfig {
blockedIps?: string[];
}
/**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
*/
function extractPorts(portRange: number | Array<number | { from: number; to: number }>): number[] {
const ports = new Set<number>();
if (typeof portRange === 'number') {
ports.add(portRange);
} else if (Array.isArray(portRange)) {
for (const entry of portRange) {
if (typeof entry === 'number') {
ports.add(entry);
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
for (let p = entry.from; p <= entry.to; p++) {
ports.add(p);
}
}
}
}
type TPerformanceIntegerField =
| 'maxStreamsPerEdge'
| 'totalWindowBudgetBytes'
| 'minStreamWindowBytes'
| 'maxStreamWindowBytes'
| 'sustainedStreamWindowBytes'
| 'quicDatagramReceiveBufferBytes'
| 'streamFramePayloadBytes'
| 'firstDataConnectTimeoutMs'
| 'clientWriteTimeoutMs';
const performanceIntegerMaxByField: Record<TPerformanceIntegerField, number> = {
maxStreamsPerEdge: 100_000,
totalWindowBudgetBytes: 1_073_741_824,
minStreamWindowBytes: 16_777_216,
maxStreamWindowBytes: 134_217_728,
sustainedStreamWindowBytes: 134_217_728,
quicDatagramReceiveBufferBytes: 67_108_864,
streamFramePayloadBytes: 16_777_216,
firstDataConnectTimeoutMs: 3_600_000,
clientWriteTimeoutMs: 3_600_000,
};
const maxServerFirstPorts = 128;
const defaultTunnelPort = 8443;
function hasOwn(objectArg: object, keyArg: string): boolean {
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
}
function extractPorts(portRange: plugins.smartproxy.IRouteConfig['match']['ports']): number[] {
const ports = new Set<number>(plugins.smartproxy.expandPortRange(portRange) as number[]);
return [...ports].sort((a, b) => a - b);
}
@@ -36,9 +50,14 @@ export class RemoteIngressManager {
private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = [];
private firewallConfig?: IRemoteIngressFirewallConfig;
private hubSettings: IRemoteIngressHubSettings = {
enabled: false,
tunnelPort: defaultTunnelPort,
updatedAt: 0,
updatedBy: 'default',
};
constructor() {
}
constructor() {}
/**
* Load all edge registrations from the database into memory.
@@ -59,12 +78,31 @@ export class RemoteIngressManager {
listenPortsUdp: doc.listenPortsUdp,
enabled: doc.enabled,
autoDerivePorts: doc.autoDerivePorts,
performance: doc.performance,
tags: doc.tags,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
this.edges.set(edge.id, edge);
}
await this.initializeHubSettings();
}
private async initializeHubSettings(): Promise<void> {
let doc = await RemoteIngressHubSettingsDoc.load();
if (!doc) {
doc = new RemoteIngressHubSettingsDoc();
doc.settingsId = 'remote-ingress-hub-settings';
doc.enabled = false;
doc.tunnelPort = defaultTunnelPort;
doc.hubDomain = '';
doc.updatedAt = Date.now();
doc.updatedBy = 'default';
await doc.save();
}
this.hubSettings = this.toHubSettings(doc);
}
/**
@@ -81,6 +119,52 @@ export class RemoteIngressManager {
this.firewallConfig = firewallConfig;
}
public getHubSettings(): IRemoteIngressHubSettings {
return {
...this.hubSettings,
performance: this.hubSettings.performance ? { ...this.hubSettings.performance } : undefined,
};
}
public getHubPerformanceConfig(): IRemoteIngressPerformanceConfig | undefined {
return this.hubSettings.performance && Object.keys(this.hubSettings.performance).length > 0
? { ...this.hubSettings.performance }
: undefined;
}
public async updateHubSettings(
updates: TRemoteIngressHubSettingsUpdate,
updatedBy: string,
): Promise<IRemoteIngressHubSettings> {
let doc = await RemoteIngressHubSettingsDoc.load();
if (!doc) {
doc = new RemoteIngressHubSettingsDoc();
doc.settingsId = 'remote-ingress-hub-settings';
doc.enabled = false;
doc.tunnelPort = defaultTunnelPort;
}
const normalized = this.normalizeHubSettingsUpdate(updates);
if (hasOwn(normalized, 'enabled')) {
doc.enabled = normalized.enabled;
}
if (hasOwn(normalized, 'tunnelPort')) {
doc.tunnelPort = normalized.tunnelPort;
}
if (hasOwn(updates, 'hubDomain')) {
doc.hubDomain = normalized.hubDomain || '';
}
if (hasOwn(updates, 'performance')) {
doc.performance = normalized.performance || undefined;
}
doc.updatedAt = Date.now();
doc.updatedBy = updatedBy;
await doc.save();
this.hubSettings = this.toHubSettings(doc);
return this.getHubSettings();
}
/**
* 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.
@@ -189,6 +273,7 @@ export class RemoteIngressManager {
listenPorts: number[] = [],
tags?: string[],
autoDerivePorts: boolean = true,
performance?: IRemoteIngressPerformanceConfig,
): Promise<IRemoteIngress> {
const id = plugins.uuid.v4();
const secret = plugins.crypto.randomBytes(32).toString('hex');
@@ -201,6 +286,7 @@ export class RemoteIngressManager {
listenPorts,
enabled: true,
autoDerivePorts,
performance,
tags: tags || [],
createdAt: now,
updatedAt: now,
@@ -237,6 +323,7 @@ export class RemoteIngressManager {
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
},
): Promise<IRemoteIngress | null> {
@@ -249,6 +336,7 @@ export class RemoteIngressManager {
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
if (updates.performance !== undefined) edge.performance = updates.performance;
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();
@@ -317,20 +405,139 @@ 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[]; firewallConfig?: IRemoteIngressFirewallConfig }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = [];
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
const performance = edge.performance && Object.keys(edge.performance).length > 0 ? edge.performance : undefined;
result.push({
id: edge.id,
secret: edge.secret,
listenPorts: this.getEffectiveListenPorts(edge),
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
...(performance ? { performance } : {}),
});
}
}
return result;
}
private normalizeHubSettingsUpdate(
updates: TRemoteIngressHubSettingsUpdate,
): TRemoteIngressHubSettingsUpdate {
const next: TRemoteIngressHubSettingsUpdate = {};
if (hasOwn(updates, 'enabled') && updates.enabled !== undefined) {
next.enabled = Boolean(updates.enabled);
}
if (hasOwn(updates, 'tunnelPort') && updates.tunnelPort !== undefined) {
const tunnelPort = Number(updates.tunnelPort);
if (!Number.isInteger(tunnelPort) || tunnelPort < 1 || tunnelPort > 65535) {
throw new Error('tunnelPort must be a valid TCP port');
}
next.tunnelPort = tunnelPort;
}
if (hasOwn(updates, 'hubDomain')) {
const hubDomain = `${updates.hubDomain || ''}`.trim();
next.hubDomain = hubDomain || undefined;
}
if (hasOwn(updates, 'performance')) {
next.performance = updates.performance === null
? undefined
: this.normalizePerformanceConfig(updates.performance || undefined);
}
return next;
}
private normalizePerformanceConfig(
performance?: IRemoteIngressPerformanceConfig,
): IRemoteIngressPerformanceConfig | undefined {
if (!performance) {
return undefined;
}
const next: IRemoteIngressPerformanceConfig = {};
const validProfiles: TRemoteIngressPerformanceProfile[] = ['balanced', 'throughput', 'highConcurrency'];
if (performance.profile !== undefined) {
if (!validProfiles.includes(performance.profile)) {
throw new Error('Invalid RemoteIngress performance profile');
}
next.profile = performance.profile;
}
const assignPositiveInteger = (field: TPerformanceIntegerField) => {
const value = performance[field];
if (value === undefined) {
return;
}
const maxValue = performanceIntegerMaxByField[field];
if (!Number.isSafeInteger(value) || value < 1 || value > maxValue) {
throw new Error(`${field} must be a positive safe integer no greater than ${maxValue}`);
}
(next as Record<string, number>)[field] = value;
};
assignPositiveInteger('maxStreamsPerEdge');
assignPositiveInteger('totalWindowBudgetBytes');
assignPositiveInteger('minStreamWindowBytes');
assignPositiveInteger('maxStreamWindowBytes');
assignPositiveInteger('sustainedStreamWindowBytes');
assignPositiveInteger('quicDatagramReceiveBufferBytes');
assignPositiveInteger('streamFramePayloadBytes');
assignPositiveInteger('firstDataConnectTimeoutMs');
assignPositiveInteger('clientWriteTimeoutMs');
if (
next.minStreamWindowBytes !== undefined
&& next.maxStreamWindowBytes !== undefined
&& next.minStreamWindowBytes > next.maxStreamWindowBytes
) {
throw new Error('minStreamWindowBytes must not exceed maxStreamWindowBytes');
}
if (
next.sustainedStreamWindowBytes !== undefined
&& next.maxStreamWindowBytes !== undefined
&& next.sustainedStreamWindowBytes > next.maxStreamWindowBytes
) {
throw new Error('sustainedStreamWindowBytes must not exceed maxStreamWindowBytes');
}
const configuredServerFirstPorts = performance.serverFirstPorts;
if (configuredServerFirstPorts !== undefined) {
if (!Array.isArray(configuredServerFirstPorts)) {
throw new Error('serverFirstPorts must contain valid port numbers');
}
if (configuredServerFirstPorts.length > maxServerFirstPorts) {
throw new Error(`serverFirstPorts must contain at most ${maxServerFirstPorts} ports`);
}
const serverFirstPorts = [...new Set(configuredServerFirstPorts.map((port) => Number(port)))].sort((a, b) => a - b);
for (const port of serverFirstPorts) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('serverFirstPorts must contain valid port numbers');
}
if (port === 443) {
throw new Error('Port 443 is client-first TLS and must not be listed as server-first');
}
}
if (serverFirstPorts.length > 0) {
next.serverFirstPorts = serverFirstPorts;
}
}
return Object.keys(next).length > 0 ? next : undefined;
}
private toHubSettings(doc: RemoteIngressHubSettingsDoc): IRemoteIngressHubSettings {
return {
enabled: doc.enabled ?? false,
tunnelPort: doc.tunnelPort ?? defaultTunnelPort,
hubDomain: doc.hubDomain || undefined,
performance: doc.performance,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
};
}
}
+53 -23
View File
@@ -1,5 +1,5 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
import type { IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
export interface ITunnelManagerConfig {
@@ -9,7 +9,7 @@ export interface ITunnelManagerConfig {
certPem?: string;
keyPem?: string;
};
performance?: import('../../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
performance?: IRemoteIngressPerformanceConfig;
}
/**
@@ -22,6 +22,8 @@ export class TunnelManager {
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
private syncChain: Promise<void> = Promise.resolve();
private reconcileChain: Promise<void> = Promise.resolve();
private stopped = true;
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
this.manager = manager;
@@ -44,18 +46,20 @@ export class TunnelManager {
this.edgeStatuses.delete(data.edgeId);
});
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
this.hub.on('streamSummary', (data: {
edgeId: string;
activeStreams: number;
streamsOpenedTotal: number;
streamsClosedTotal: number;
}) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing) {
existing.activeTunnels++;
existing.activeTunnels = data.activeStreams;
existing.lastHeartbeat = Date.now();
}
});
this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing && existing.activeTunnels > 0) {
existing.activeTunnels--;
if (existing.traffic) {
existing.traffic.streamsOpenedTotal = data.streamsOpenedTotal;
existing.traffic.streamsClosedTotal = data.streamsClosedTotal;
}
}
});
}
@@ -64,30 +68,52 @@ export class TunnelManager {
* Start the tunnel hub and load allowed edges.
*/
public async start(): Promise<void> {
await this.hub.start({
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);
this.stopped = false;
try {
await this.hub.start({
tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
...(this.config.performance ? { performance: this.config.performance } : {}),
streamEventMode: 'summary',
} as any);
// Send allowed edges to the hub
await this.syncAllowedEdges();
if (this.stopped) return;
// Periodically reconcile with authoritative Rust hub status
this.reconcileInterval = setInterval(() => {
this.reconcile().catch(() => {});
}, 15_000);
// Send allowed edges to the hub
await this.syncAllowedEdges();
if (this.stopped) return;
// Periodically reconcile with authoritative Rust hub status
this.reconcileInterval = setInterval(() => {
this.reconcileChain = this.reconcileChain
.catch(() => {})
.then(() => this.reconcile());
this.reconcileChain.catch(() => {});
}, 15_000);
} catch (err) {
await this.stop();
throw err;
}
}
/**
* Stop the tunnel hub.
*/
public async stop(): Promise<void> {
if (this.stopped) {
return;
}
this.stopped = true;
if (this.reconcileInterval) {
clearInterval(this.reconcileInterval);
this.reconcileInterval = null;
}
await Promise.all([
this.syncChain.catch(() => {}),
this.reconcileChain.catch(() => {}),
]);
// Remove event listeners before stopping to prevent leaks
this.hub.removeAllListeners();
await this.hub.stop();
@@ -99,7 +125,9 @@ export class TunnelManager {
* Overwrites event-derived activeTunnels with the real activeStreams count.
*/
private async reconcile(): Promise<void> {
if (this.stopped) return;
const hubStatus = await this.hub.getStatus();
if (this.stopped) return;
if (!hubStatus || !hubStatus.connectedEdges) return;
const rustEdgeIds = new Set<string>();
@@ -144,7 +172,9 @@ export class TunnelManager {
*/
public async syncAllowedEdges(): Promise<void> {
const run = this.syncChain.catch(() => {}).then(async () => {
if (this.stopped) return;
const edges = this.manager.getAllowedEdges();
if (this.stopped) return;
await this.hub.updateAllowedEdges(edges as any);
});
this.syncChain = run;
+115 -7
View File
@@ -19,12 +19,24 @@ 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 Set<string>();
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 = {}) {
@@ -37,6 +49,9 @@ export class SecurityPolicyManager {
}
public async stop(): Promise<void> {
this.isStopping = true;
this.observationQueue.length = 0;
this.queuedObservations.clear();
await this.smartNetwork.stop();
}
@@ -45,13 +60,55 @@ export class SecurityPolicyManager {
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) || this.inFlightObservations.has(ip)) {
if (!ip || !this.isPublicIp(ip)) {
return;
}
this.inFlightObservations.add(ip);
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);
@@ -81,8 +138,6 @@ export class SecurityPolicyManager {
}
} catch (err) {
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
} finally {
this.inFlightObservations.delete(ip);
}
}
@@ -90,8 +145,22 @@ export class SecurityPolicyManager {
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
}
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(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> {
@@ -104,6 +173,45 @@ export class SecurityPolicyManager {
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,
+214 -26
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[];
@@ -57,6 +61,9 @@ export class VpnManager {
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;
@@ -111,6 +118,7 @@ export class VpnManager {
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
const serverEndpoint = this.getWireGuardServerEndpoint();
const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
@@ -133,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,
socketForwardProxyProtocolSource: 'remoteIp',
socketForwardProxyProtocolVpnMetadata: true,
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
serverEndpoint: this.config.serverEndpoint
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
serverEndpoint,
clientAllowedIPs: [subnet],
// Bridge-specific config
...(isBridge ? {
@@ -174,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}`);
}
@@ -181,15 +192,21 @@ 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');
}
@@ -244,14 +261,11 @@ export class VpnManager {
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(doc.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)
doc.clientId = bundle.entry.clientId;
@@ -292,6 +306,7 @@ 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();
}
@@ -333,6 +348,7 @@ export class VpnManager {
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.clientSourceIps.delete(clientId);
this.config.onClientChanged?.();
}
@@ -381,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 || '';
@@ -414,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;
@@ -454,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.
*/
@@ -515,6 +633,51 @@ export class VpnManager {
}
}
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> {
@@ -532,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;
@@ -556,6 +719,31 @@ 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
+8
View File
@@ -12,6 +12,14 @@ export class WorkHosterManager {
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',
+4 -4
View File
@@ -27,7 +27,7 @@ const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
});
await client.login('admin', 'admin');
await client.login('admin@example.com', 'strong-password');
const { routes, warnings } = await client.routes.list();
console.log(routes.length, warnings.length);
@@ -43,13 +43,13 @@ await route.toggle(true);
## Authentication
The client supports session login and API-token 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', 'admin');
await sessionClient.login('admin@example.com', 'strong-password');
const tokenClient = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
@@ -153,7 +153,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.
+1 -3
View File
@@ -1,9 +1,7 @@
/**
* ACME configuration for automated TLS certificate issuance via Let's Encrypt.
*
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb. Replaces the
* legacy constructor fields `tls.contactEmail` / `smartProxyConfig.acme.*`
* which are now seed-only (used once on first boot if the DB is empty).
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb.
*
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
*/

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