Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b55d2ac61d | |||
| c88e8e1758 | |||
| 6ee716e4ef | |||
| 1d4ed9af2c | |||
| d2331fdcbe | |||
| 0e7765c740 | |||
| 1a381df937 | |||
| 38e2f3cee1 | |||
| 4a47460bf1 | |||
| 3679cba3a4 | |||
| 3dc0371f7e | |||
| b212662764 | |||
| 776c65a18c | |||
| 5f6ec63770 | |||
| 1b4cc0567f | |||
| 22de50b544 | |||
| 2e3bead40c | |||
| 85065b05c8 | |||
| 7f7a26fb38 | |||
| a089b681c4 | |||
| 3e71301bf5 | |||
| 58cc8c0753 | |||
| e279814803 | |||
| 6bee2eb172 | |||
| db8ea99e88 | |||
| 98ccf82af0 | |||
| 0f99525612 | |||
| 8e707d9c4d | |||
| 418c825b01 | |||
| 75f29af27f | |||
| 4467fe629a | |||
| 1912feffe5 | |||
| 9077b3dad6 | |||
| d09ac51c5b | |||
| 9d7975721d | |||
| 667d62b456 | |||
| 90b1ca8de3 | |||
| 17d824d718 | |||
| 06a8636aee | |||
| 4bf08c1fc3 | |||
| 7e721c54d0 | |||
| e6aa5a1dd2 | |||
| bbe18e1413 | |||
| e2a10bdc3c | |||
| 42a5f6df7b | |||
| c61d832b43 | |||
| 872a822ed7 | |||
| 34bfd1528b | |||
| be38808795 | |||
| b9ae4ac344 | |||
| 37adcc9ddc | |||
| ac118397f9 | |||
| 8188b4712c | |||
| 27d077feed | |||
| 98913c1977 | |||
| ca5c57a329 | |||
| 707fbc2413 | |||
| a0c9d40e87 | |||
| 2a73973eda | |||
| f0069f87e2 | |||
| 77c1738390 |
@@ -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
|
||||
+23
-1
@@ -29,6 +29,28 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"@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",
|
||||
@@ -96,4 +118,4 @@
|
||||
]
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
process.env.CLI_CALL = 'true';
|
||||
|
||||
const cliTool = await import('../dist_ts/index.js');
|
||||
await cliTool.runCli();
|
||||
+267
@@ -6,6 +6,273 @@
|
||||
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.42.2",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
"dist_serve"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/server",
|
||||
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
|
||||
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
|
||||
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
|
||||
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.1",
|
||||
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
|
||||
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
|
||||
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.2",
|
||||
"@push.rocks/smartmetrics": "npm:@push.rocks/smartmetrics@^3.0.3",
|
||||
"@push.rocks/smartmigration": "npm:@push.rocks/smartmigration@1.4.1",
|
||||
"@push.rocks/smartmta": "npm:@push.rocks/smartmta@^5.3.3",
|
||||
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
|
||||
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
|
||||
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.3",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
|
||||
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
|
||||
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
|
||||
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
|
||||
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
|
||||
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
|
||||
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.4",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
|
||||
"lru-cache": "npm:lru-cache@^11.4.0",
|
||||
"qrcode": "npm:qrcode@^1.5.4",
|
||||
"uuid": "npm:uuid@^14.0.0"
|
||||
}
|
||||
}
|
||||
Executable
+359
@@ -0,0 +1,359 @@
|
||||
#!/bin/bash
|
||||
|
||||
# DcRouter Installer Script
|
||||
# Installs the self-extracting Linux binary by default, or builds the NodeNext
|
||||
# source package when --source is specified.
|
||||
#
|
||||
# Usage:
|
||||
# Binary install:
|
||||
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
|
||||
#
|
||||
# Source install:
|
||||
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
|
||||
#
|
||||
# Options:
|
||||
# -h, --help Show this help message
|
||||
# --version VERSION Install a specific tag/version (e.g. vX.Y.Z)
|
||||
# --install-dir DIR Installation directory (default: /opt/dcrouter)
|
||||
# --binary Install release binary (default)
|
||||
# --source Clone the tag and build the NodeNext package locally
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SHOW_HELP=0
|
||||
SPECIFIED_VERSION=""
|
||||
INSTALL_DIR="/opt/dcrouter"
|
||||
INSTALL_MODE="binary"
|
||||
GITEA_BASE_URL="https://code.foss.global"
|
||||
GITEA_REPO="serve.zone/dcrouter"
|
||||
SERVICE_NAME="dcrouter"
|
||||
BIN_DIR="/usr/local/bin"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
SHOW_HELP=1
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --version requires a value"
|
||||
exit 1
|
||||
fi
|
||||
SPECIFIED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-dir)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --install-dir requires a value"
|
||||
exit 1
|
||||
fi
|
||||
INSTALL_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--binary)
|
||||
INSTALL_MODE="binary"
|
||||
shift
|
||||
;;
|
||||
--source)
|
||||
INSTALL_MODE="source"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use -h or --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $SHOW_HELP -eq 1 ]]; then
|
||||
echo "DcRouter Installer Script"
|
||||
echo "Installs DcRouter as a self-extracting binary or NodeNext source build."
|
||||
echo ""
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " --version VERSION Install a specific tag/version (e.g. vX.Y.Z)"
|
||||
echo " --install-dir DIR Installation directory (default: /opt/dcrouter)"
|
||||
echo " --binary Install release binary (default)"
|
||||
echo " --source Clone the tag and build the NodeNext package locally"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --version vX.Y.Z"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$INSTALL_DIR" in
|
||||
""|"/")
|
||||
echo "Error: unsafe install directory: $INSTALL_DIR"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Error: required command not found: $1"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_pnpm() {
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
if command -v corepack >/dev/null 2>&1; then
|
||||
corepack enable
|
||||
fi
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "Error: pnpm is required for --source installs. Install Node.js with corepack/pnpm first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
make_executable_if_present() {
|
||||
if [[ -f "$1" ]]; then
|
||||
chmod 0755 "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
get_latest_version() {
|
||||
echo "Fetching latest release version from Gitea..." >&2
|
||||
|
||||
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
|
||||
local response
|
||||
if ! response=$(curl -fsSL "$api_url" 2>/dev/null); then
|
||||
echo "Error: Failed to fetch latest release information from Gitea API" >&2
|
||||
echo "URL: $api_url" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local version
|
||||
version=$(printf '%s' "$response" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "Error: Could not determine latest version from API response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
detect_binary_name() {
|
||||
local os
|
||||
local arch
|
||||
os=$(uname -s)
|
||||
arch=$(uname -m)
|
||||
|
||||
if [[ "$os" != "Linux" ]]; then
|
||||
echo "Error: binary installer currently supports Linux only. Use --source for this platform." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
echo "dcrouter-linux-x64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "dcrouter-linux-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Error: unsupported architecture for binary install: $arch. Use --source." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "================================================"
|
||||
echo " DcRouter Installation Script"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
require_command curl
|
||||
require_command sed
|
||||
|
||||
if [[ -n "$SPECIFIED_VERSION" ]]; then
|
||||
VERSION="$SPECIFIED_VERSION"
|
||||
echo "Installing specified version: $VERSION"
|
||||
else
|
||||
VERSION=$(get_latest_version)
|
||||
echo "Installing latest version: $VERSION"
|
||||
fi
|
||||
echo "Install mode: $INSTALL_MODE"
|
||||
echo ""
|
||||
|
||||
SOURCE_REF="$VERSION"
|
||||
REPO_URL="${GITEA_BASE_URL}/${GITEA_REPO}.git"
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
SOURCE_DIR="$TEMP_DIR/source"
|
||||
BACKUP_DIR=""
|
||||
SERVICE_WAS_RUNNING=0
|
||||
SERVICE_STOPPED=0
|
||||
SYSTEMD_AVAILABLE=0
|
||||
|
||||
cleanup_temp() {
|
||||
rm -rf "$TEMP_DIR"
|
||||
}
|
||||
trap cleanup_temp EXIT
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
SYSTEMD_AVAILABLE=1
|
||||
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
SERVICE_WAS_RUNNING=1
|
||||
fi
|
||||
fi
|
||||
|
||||
restore_previous_installation() {
|
||||
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
|
||||
echo "Restoring previous installation from $BACKUP_DIR..."
|
||||
rm -rf "$INSTALL_DIR" || true
|
||||
mv "$BACKUP_DIR" "$INSTALL_DIR" || true
|
||||
if [[ -f "$INSTALL_DIR/dcrouter" ]]; then
|
||||
mkdir -p "$BIN_DIR" || true
|
||||
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter" || true
|
||||
elif [[ -f "$INSTALL_DIR/cli.js" ]]; then
|
||||
mkdir -p "$BIN_DIR" || true
|
||||
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter" || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
restart_previous_service_on_error() {
|
||||
if [[ $SERVICE_STOPPED -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
|
||||
echo "Installation failed after stopping DcRouter; restarting previous service..."
|
||||
systemctl start "$SERVICE_NAME" || true
|
||||
fi
|
||||
}
|
||||
|
||||
handle_install_error() {
|
||||
trap - ERR
|
||||
restore_previous_installation
|
||||
restart_previous_service_on_error
|
||||
}
|
||||
trap handle_install_error ERR
|
||||
|
||||
stop_service_if_running() {
|
||||
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
echo "Stopping DcRouter service..."
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
SERVICE_STOPPED=1
|
||||
fi
|
||||
}
|
||||
|
||||
move_previous_installation() {
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
if [[ -d "$INSTALL_DIR" ]]; then
|
||||
BACKUP_DIR="${INSTALL_DIR}.previous.$$"
|
||||
echo "Moving previous installation to $BACKUP_DIR"
|
||||
mv "$INSTALL_DIR" "$BACKUP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
install_source_build() {
|
||||
require_command git
|
||||
require_command node
|
||||
ensure_pnpm
|
||||
|
||||
echo "Cloning DcRouter source from $REPO_URL ($SOURCE_REF)..."
|
||||
git clone --depth 1 --branch "$SOURCE_REF" "$REPO_URL" "$SOURCE_DIR"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pnpm --dir "$SOURCE_DIR" install --frozen-lockfile
|
||||
|
||||
echo "Building DcRouter..."
|
||||
pnpm --dir "$SOURCE_DIR" run build
|
||||
|
||||
echo "Validating built CLI..."
|
||||
node "$SOURCE_DIR/cli.js" --version >/dev/null
|
||||
|
||||
stop_service_if_running
|
||||
move_previous_installation
|
||||
|
||||
echo "Installing source build to $INSTALL_DIR"
|
||||
mv "$SOURCE_DIR" "$INSTALL_DIR"
|
||||
make_executable_if_present "$INSTALL_DIR/cli.js"
|
||||
make_executable_if_present "$INSTALL_DIR/cli.ts.js"
|
||||
make_executable_if_present "$INSTALL_DIR/cli.child.js"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter"
|
||||
}
|
||||
|
||||
install_release_binary() {
|
||||
local binary_name
|
||||
local download_url
|
||||
local temp_file
|
||||
|
||||
binary_name=$(detect_binary_name)
|
||||
download_url="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${binary_name}"
|
||||
temp_file="$TEMP_DIR/$binary_name"
|
||||
|
||||
echo "Downloading DcRouter binary: $download_url"
|
||||
curl -fSL "$download_url" -o "$temp_file"
|
||||
chmod 0755 "$temp_file"
|
||||
|
||||
echo "Validating downloaded binary..."
|
||||
"$temp_file" --version >/dev/null
|
||||
|
||||
stop_service_if_running
|
||||
move_previous_installation
|
||||
|
||||
echo "Installing binary to $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
install -m 0755 "$temp_file" "$INSTALL_DIR/dcrouter"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter"
|
||||
}
|
||||
|
||||
if [[ "$INSTALL_MODE" == "source" ]]; then
|
||||
install_source_build
|
||||
else
|
||||
install_release_binary
|
||||
fi
|
||||
|
||||
echo "Symlink created: $BIN_DIR/dcrouter"
|
||||
|
||||
if ! "$BIN_DIR/dcrouter" --version >/dev/null; then
|
||||
echo "Error: Installed DcRouter CLI failed validation"
|
||||
restore_previous_installation
|
||||
restart_previous_service_on_error
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
fi
|
||||
|
||||
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
|
||||
echo "Restarting DcRouter service..."
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
SERVICE_STOPPED=0
|
||||
echo "Service restarted successfully."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
trap - ERR
|
||||
|
||||
echo "================================================"
|
||||
echo " DcRouter Installation Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Installation details:"
|
||||
echo " Install directory: $INSTALL_DIR"
|
||||
echo " Symlink location: $BIN_DIR/dcrouter"
|
||||
echo " Version: $VERSION"
|
||||
echo " Mode: $INSTALL_MODE"
|
||||
echo ""
|
||||
echo "Get started:"
|
||||
echo ""
|
||||
echo " dcrouter --version"
|
||||
echo " dcrouter --help"
|
||||
echo ""
|
||||
+25
-23
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.31.0",
|
||||
"version": "13.42.2",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"dcrouter": "./cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js",
|
||||
"./interfaces": "./dist_ts_interfaces/index.js",
|
||||
@@ -15,20 +18,22 @@
|
||||
"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.1",
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdocker": "^2.3.0",
|
||||
"@git.zone/tsdeno": "^1.5.0",
|
||||
"@git.zone/tsdocker": "^2.4.1",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@types/node": "^25.9.0"
|
||||
"@types/node": "^25.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.1",
|
||||
@@ -36,7 +41,7 @@
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.3",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-catalog": "^3.83.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@idp.global/sdk": "^1.3.1",
|
||||
"@push.rocks/lik": "^6.4.1",
|
||||
@@ -44,29 +49,29 @@
|
||||
"@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/smartdb": "^2.10.1",
|
||||
"@push.rocks/smartdns": "^7.9.3",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.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.2",
|
||||
"@push.rocks/smartradius": "^1.1.2",
|
||||
"@push.rocks/smartproxy": "^27.12.4",
|
||||
"@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.4",
|
||||
"@push.rocks/smartvpn": "1.20.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.8.0",
|
||||
"@serve.zone/remoteingress": "^4.17.1",
|
||||
"@serve.zone/remoteingress": "^4.22.5",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.4.0",
|
||||
@@ -99,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"
|
||||
]
|
||||
|
||||
Generated
+100
-79
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^3.81.0
|
||||
version: 3.81.0(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.83.0
|
||||
version: 3.83.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
@@ -48,11 +48,11 @@ importers:
|
||||
specifier: ^7.1.7
|
||||
version: 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartdb':
|
||||
specifier: ^2.10.0
|
||||
version: 2.10.0(@tiptap/pm@2.27.2)(socks@2.8.8)
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1(@tiptap/pm@2.27.2)(socks@2.8.8)
|
||||
'@push.rocks/smartdns':
|
||||
specifier: ^7.9.2
|
||||
version: 7.9.2
|
||||
specifier: ^7.9.3
|
||||
version: 7.9.3
|
||||
'@push.rocks/smartfs':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
@@ -69,14 +69,14 @@ importers:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@push.rocks/smartmigration':
|
||||
specifier: 1.3.1
|
||||
version: 1.3.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))
|
||||
specifier: 1.4.1
|
||||
version: 1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))
|
||||
'@push.rocks/smartmta':
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
'@push.rocks/smartnetwork':
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1
|
||||
specifier: ^4.7.2
|
||||
version: 4.7.3
|
||||
'@push.rocks/smartpath':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -84,11 +84,11 @@ importers:
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.10.2
|
||||
version: 27.10.2
|
||||
specifier: ^27.12.4
|
||||
version: 27.12.4
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
'@push.rocks/smartrequest':
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3
|
||||
@@ -102,8 +102,8 @@ importers:
|
||||
specifier: ^3.0.9
|
||||
version: 3.0.9
|
||||
'@push.rocks/smartvpn':
|
||||
specifier: 1.19.4
|
||||
version: 1.19.4
|
||||
specifier: 1.20.0
|
||||
version: 1.20.0
|
||||
'@push.rocks/taskbuffer':
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
@@ -114,8 +114,8 @@ importers:
|
||||
specifier: ^5.8.0
|
||||
version: 5.8.0
|
||||
'@serve.zone/remoteingress':
|
||||
specifier: ^4.17.1
|
||||
version: 4.17.1
|
||||
specifier: ^4.22.5
|
||||
version: 4.22.5
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
@@ -133,14 +133,17 @@ importers:
|
||||
version: 14.0.0
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
'@git.zone/tsbundle':
|
||||
specifier: ^2.10.4
|
||||
version: 2.10.4
|
||||
'@git.zone/tsdeno':
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0
|
||||
'@git.zone/tsdocker':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1
|
||||
'@git.zone/tsrun':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
@@ -151,8 +154,8 @@ importers:
|
||||
specifier: ^3.3.5
|
||||
version: 3.3.5(@tiptap/pm@2.27.2)
|
||||
'@types/node':
|
||||
specifier: ^25.9.0
|
||||
version: 25.9.0
|
||||
specifier: ^25.9.1
|
||||
version: 25.9.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -362,8 +365,8 @@ packages:
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
'@design.estate/dees-catalog@3.81.0':
|
||||
resolution: {integrity: sha512-N7ocwSKVdjDQWmVV2XWiyg3dotGEuxP4/jhyB6duH8zJ3k63wmGm8+FeoP+LzRc8/U0Bl8w7UZrewlkIEMstUA==}
|
||||
'@design.estate/dees-catalog@3.83.0':
|
||||
resolution: {integrity: sha512-Ia4fwZ5ndziJkSE000nCro83rD8Rujki7ASHBQhL6ZDflZRJRlfuc13azVnQC2sazKlo/bWSgiiLcpc3V2IYrw==}
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||
@@ -718,16 +721,20 @@ packages:
|
||||
resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
'@git.zone/tsbuild@4.4.1':
|
||||
resolution: {integrity: sha512-usxx8BBQsAypxjFOfd1GEV9pL9EUshRKktXtRWHMDByb6ps83+PdUIb3D7O+nkkBp4C9PXo3cfbsR4Asvo33CA==}
|
||||
'@git.zone/tsbuild@4.4.2':
|
||||
resolution: {integrity: sha512-v2m0fFYFt3vJZMvNAlrNChHYjZZNOf4iyO0mNNiHeO+sTR3cddkYb++zO/GL3v2UkG3nDRwfEkwUS4UzuXBEWw==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsbundle@2.10.4':
|
||||
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsdocker@2.3.0':
|
||||
resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==}
|
||||
'@git.zone/tsdeno@1.5.0':
|
||||
resolution: {integrity: sha512-OdGPhnBz6v92OkKKWyswpyGman3m3FOXin+9WRzEBvvwyLAAkc2mKUGViPAIxYkrak4GiglzqjTkSyReDU0QOw==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsdocker@2.4.1':
|
||||
resolution: {integrity: sha512-T7c0yf8eUNsT+ZYACv43FZqTY5OvhR+quSrc6+zgvAyzRIN4rG4t5lO0tyXkiakKp9QnIkjnLSPqWqeoP68jzQ==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tspublish@1.11.6':
|
||||
@@ -1279,14 +1286,14 @@ packages:
|
||||
'@push.rocks/smartdata@7.1.7':
|
||||
resolution: {integrity: sha512-HDI/Q9dKybfsJ68oCzlE+S63Xpij9qXnMfi28yznKP0Li1ECVZZMDDGIW5IjsXlHjO+Q+RJMcVd72Pjt3QLY5Q==}
|
||||
|
||||
'@push.rocks/smartdb@2.10.0':
|
||||
resolution: {integrity: sha512-f7Sm861LJqBxgpX3ybNeRSShothSTLJsFETh1Vfj0WdC+oUZSOgIDqfQcR/gy25hc3eSnk1Bd5zz4cbWh9wosg==}
|
||||
'@push.rocks/smartdb@2.10.1':
|
||||
resolution: {integrity: sha512-m33HbSZdvUjCIucHWuJRK4ly7c0fsnL1hJAjZdjf6WqaFlWAjR0SztZp/V/u1yGP7IIcaXMXaWAijB9BC91Dvg==}
|
||||
|
||||
'@push.rocks/smartdelay@3.1.0':
|
||||
resolution: {integrity: sha512-59xveBMbWmbFhh/rqhQnYG/klg/VONG9hV8+RQ7ftqsNRkcmUT+VM5etAbODgAUvsF4lxK+xVR0tbZOo0kGhRQ==}
|
||||
|
||||
'@push.rocks/smartdns@7.9.2':
|
||||
resolution: {integrity: sha512-joMroNy/1YjXjxUaW38HQTvlyRHETE2+vnKg1c1304gHqcThyRawtdcnQsvmoK9sO1ZaPAqBKL1QP9m87nCFYQ==}
|
||||
'@push.rocks/smartdns@7.9.3':
|
||||
resolution: {integrity: sha512-TkqDmYeO0ogIICWIM06hE/SeNpyASsqr7d+HJv8u3FyD2jRP9LHn0X0o8CjSJ+IoTHSNXFBDFrddyysFdnwSsg==}
|
||||
|
||||
'@push.rocks/smartenv@5.0.13':
|
||||
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
|
||||
@@ -1366,8 +1373,8 @@ packages:
|
||||
'@push.rocks/smartmetrics@3.0.3':
|
||||
resolution: {integrity: sha512-RYY4NOla3kraZYVF9TBHgIz4/hSkqVDVNP7tLwhLK5mGBPBy8I/9NWXX6txZKQw6QihP85YD8mWUuUu2xS4D6Q==}
|
||||
|
||||
'@push.rocks/smartmigration@1.3.1':
|
||||
resolution: {integrity: sha512-qU3vc4yCLn8vJQIEMQwS2Lq6Ra8ixSfjutnbR1L/hauCzFRCic3o/DnFKB7pjj5jWaqSDG5nlyeIliLmC5aGsg==}
|
||||
'@push.rocks/smartmigration@1.4.1':
|
||||
resolution: {integrity: sha512-kBvWuqBIIgkK2QskjHl0/MPLXYu4CDJDyuPc1KBDPBNejYIJp6hOZtbsmj4DYoNKsgFTpAALJn9JmUEdLe9E4g==}
|
||||
peerDependencies:
|
||||
'@push.rocks/smartbucket': ^4.6.1
|
||||
'@push.rocks/smartdata': ^7.1.7
|
||||
@@ -1395,8 +1402,8 @@ packages:
|
||||
'@push.rocks/smartmustache@3.0.2':
|
||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||
|
||||
'@push.rocks/smartnetwork@4.7.1':
|
||||
resolution: {integrity: sha512-x9SolGn8lU3oh+fKL26dR5dIhsus5f0p/Xiaut2pK5Wamgwrvt5y5To8F+pzF1pQr6yA0XwWZ0Dgoppp2E+ziQ==}
|
||||
'@push.rocks/smartnetwork@4.7.3':
|
||||
resolution: {integrity: sha512-ecv8aSGbcHUDkE0IJ+/0mRpgQv1fSjQAgcTe1qgBNY1Lk8lQTTaNjpG7g21EdK23seyShewejtGKOcK5o7Rh6A==}
|
||||
|
||||
'@push.rocks/smartnftables@1.2.0':
|
||||
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
|
||||
@@ -1422,14 +1429,14 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.4':
|
||||
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
|
||||
|
||||
'@push.rocks/smartproxy@27.10.2':
|
||||
resolution: {integrity: sha512-ycTJ3OZ/LUAO0OY06O2al41bhm3s6mT9D5LcL7RepLyShjHBsaC26FNEApIVh9tll7OMHtsOa9ejOWQ8zuA4pA==}
|
||||
'@push.rocks/smartproxy@27.12.4':
|
||||
resolution: {integrity: sha512-oqr4IE4hqNOL38RRzCux/dF0PjKjeIf4Z/vbe2JXZUz9ZF2ANNPsLSvs0Z/LFlfsrvV35k0rqeEmBvKIq+1lVQ==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.6':
|
||||
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
|
||||
|
||||
'@push.rocks/smartradius@1.1.2':
|
||||
resolution: {integrity: sha512-p4fHhMgXZRuyRuMQjFQLVnXBG1Fz2latJ7BGAsfInOuVUaitBr/Wni9mZULAuIIddeWwUx9QvIGlv3tgmFn/ow==}
|
||||
'@push.rocks/smartradius@1.3.0':
|
||||
resolution: {integrity: sha512-97BQhVT5gdDTNfb8LZiqaPddTMlx5Eqpsj7jTBQ2kj4tYpK0YWRiKkpBxxEXTjsIsq7iTxHeNTwc8kMZj+yU3g==}
|
||||
|
||||
'@push.rocks/smartrequest@2.1.0':
|
||||
resolution: {integrity: sha512-3eHLTRInHA+u+W98TqJwgTES7rRimBAsJC4JxVNQC3UUezmblAhM5/TIQsEBQTsbjAY8SeQKy6NHzW6iTiaD8w==}
|
||||
@@ -1482,8 +1489,8 @@ packages:
|
||||
'@push.rocks/smartversion@3.1.0':
|
||||
resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==}
|
||||
|
||||
'@push.rocks/smartvpn@1.19.4':
|
||||
resolution: {integrity: sha512-Cp6yyzRcZlqQMEWAQ/CG2tvUxSR4eSmzMTDQFVJsPtV+CbhXpulbqqz0penU6drVMiRGzXhwoQZtGYynigIXwA==}
|
||||
'@push.rocks/smartvpn@1.20.0':
|
||||
resolution: {integrity: sha512-k5cdbHGtCUMcZTwJr+7BwXNFxbeXZEe5MZ00y/f2Isi8yLAdfmdBJ5o32vwR0LJvWm2ZFn7ST8S1AkCY/K9L3w==}
|
||||
|
||||
'@push.rocks/smartwatch@6.4.0':
|
||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||
@@ -1712,8 +1719,9 @@ packages:
|
||||
'@serve.zone/interfaces@5.8.0':
|
||||
resolution: {integrity: sha512-0ekSKUL/b44wmmzuCRANzrjaJRAHtkqiL8cPiMASEs7UJBDqbJCrgtrlJK84pz5dxBz3jTcdznNd5qjB8c6H0A==}
|
||||
|
||||
'@serve.zone/remoteingress@4.17.1':
|
||||
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
|
||||
'@serve.zone/remoteingress@4.22.5':
|
||||
resolution: {integrity: sha512-P2aQ/0VLPATbIBMYm4DT2XqLnBa15WXB1HcyE7RMVNC5RntGbmfj5FES3R5OgxY1V00E7wTMTCNrkfbKhj5xqQ==}
|
||||
hasBin: true
|
||||
|
||||
'@smithy/chunked-blob-reader-native@4.2.3':
|
||||
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
|
||||
@@ -2158,8 +2166,8 @@ packages:
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/node@25.9.0':
|
||||
resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==}
|
||||
'@types/node@25.9.1':
|
||||
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
@@ -4376,7 +4384,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
|
||||
'@cloudflare/workers-types': 4.20260507.1
|
||||
'@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
@@ -4910,7 +4918,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
'@design.estate/dees-catalog@3.81.0(@tiptap/pm@2.27.2)':
|
||||
'@design.estate/dees-catalog@3.83.0(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.6
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
@@ -5194,7 +5202,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@fortawesome/fontawesome-common-types': 7.2.0
|
||||
|
||||
'@git.zone/tsbuild@4.4.1':
|
||||
'@git.zone/tsbuild@4.4.2':
|
||||
dependencies:
|
||||
'@git.zone/tspublish': 1.11.6
|
||||
'@push.rocks/early': 4.0.4
|
||||
@@ -5243,7 +5251,20 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@git.zone/tsdocker@2.3.0':
|
||||
'@git.zone/tsdeno@1.5.0':
|
||||
dependencies:
|
||||
'@push.rocks/early': 4.0.4
|
||||
'@push.rocks/smartcli': 4.0.21
|
||||
'@push.rocks/smartconfig': 6.1.1
|
||||
'@push.rocks/smartfs': 1.5.1
|
||||
'@push.rocks/smartshell': 3.3.8
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@git.zone/tsdocker@2.4.1':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/projectinfo': 5.1.0
|
||||
@@ -5306,7 +5327,7 @@ snapshots:
|
||||
'@push.rocks/smartjson': 6.0.1
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartmongo': 7.0.0(socks@2.8.8)
|
||||
'@push.rocks/smartnetwork': 4.7.1
|
||||
'@push.rocks/smartnetwork': 4.7.3
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
'@push.rocks/smartrequest': 5.0.3
|
||||
@@ -5370,7 +5391,7 @@ snapshots:
|
||||
|
||||
'@happy-dom/global-registrator@20.9.0':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
happy-dom: 20.9.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -6113,9 +6134,9 @@ snapshots:
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartdns': 7.9.2
|
||||
'@push.rocks/smartdns': 7.9.3
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartnetwork': 4.7.1
|
||||
'@push.rocks/smartnetwork': 4.7.3
|
||||
'@push.rocks/smartstring': 4.1.1
|
||||
'@push.rocks/smarttime': 4.2.3
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
@@ -6293,7 +6314,7 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/smartdb@2.10.0(@tiptap/pm@2.27.2)(socks@2.8.8)':
|
||||
'@push.rocks/smartdb@2.10.1(@tiptap/pm@2.27.2)(socks@2.8.8)':
|
||||
dependencies:
|
||||
'@api.global/typedserver': 8.4.6(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
@@ -6323,7 +6344,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
|
||||
'@push.rocks/smartdns@7.9.2':
|
||||
'@push.rocks/smartdns@7.9.3':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartenv': 6.1.0
|
||||
@@ -6474,7 +6495,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartmail@2.2.1':
|
||||
dependencies:
|
||||
'@push.rocks/smartdns': 7.9.2
|
||||
'@push.rocks/smartdns': 7.9.3
|
||||
'@push.rocks/smartfile': 13.1.3
|
||||
'@push.rocks/smartmustache': 3.0.2
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
@@ -6508,7 +6529,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
|
||||
'@push.rocks/smartmigration@1.3.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))':
|
||||
'@push.rocks/smartmigration@1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))':
|
||||
dependencies:
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartversion': 3.1.0
|
||||
@@ -6591,9 +6612,9 @@ snapshots:
|
||||
dependencies:
|
||||
handlebars: 4.7.9
|
||||
|
||||
'@push.rocks/smartnetwork@4.7.1':
|
||||
'@push.rocks/smartnetwork@4.7.3':
|
||||
dependencies:
|
||||
'@push.rocks/smartdns': 7.9.2
|
||||
'@push.rocks/smartdns': 7.9.3
|
||||
'@push.rocks/smartrust': 1.4.0
|
||||
maxmind: 5.0.6
|
||||
transitivePeerDependencies:
|
||||
@@ -6654,7 +6675,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartfs': 1.5.1
|
||||
'@push.rocks/smartjimp': 1.2.1
|
||||
'@push.rocks/smartnetwork': 4.7.1
|
||||
'@push.rocks/smartnetwork': 4.7.3
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
'@push.rocks/smartpuppeteer': 2.0.6(typescript@6.0.3)
|
||||
@@ -6675,7 +6696,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.4': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.10.2':
|
||||
'@push.rocks/smartproxy@27.12.4':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
@@ -6699,7 +6720,7 @@ snapshots:
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
'@push.rocks/smartradius@1.1.2':
|
||||
'@push.rocks/smartradius@1.3.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
@@ -6822,7 +6843,7 @@ snapshots:
|
||||
'@types/semver': 7.7.1
|
||||
semver: 7.7.4
|
||||
|
||||
'@push.rocks/smartvpn@1.19.4':
|
||||
'@push.rocks/smartvpn@1.20.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartnftables': 1.2.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
@@ -7047,7 +7068,7 @@ snapshots:
|
||||
|
||||
'@serve.zone/catalog@2.12.4(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-domtools': 2.5.6
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.9.0
|
||||
@@ -7064,7 +7085,7 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/remoteingress@4.17.1':
|
||||
'@serve.zone/remoteingress@4.22.5':
|
||||
dependencies:
|
||||
'@push.rocks/qenv': 6.1.4
|
||||
'@push.rocks/smartnftables': 1.2.0
|
||||
@@ -7583,7 +7604,7 @@ snapshots:
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
@@ -7611,7 +7632,7 @@ snapshots:
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
@@ -7632,16 +7653,16 @@ snapshots:
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/node@16.9.1': {}
|
||||
|
||||
@@ -7653,13 +7674,13 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.9.0':
|
||||
'@types/node@25.9.1':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/randomatic@3.1.5': {}
|
||||
|
||||
@@ -7671,7 +7692,7 @@ snapshots:
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -7699,11 +7720,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
@@ -8423,7 +8444,7 @@ snapshots:
|
||||
|
||||
happy-dom@20.9.0:
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
entities: 7.0.1
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
mongodb-memory-server: true
|
||||
puppeteer: true
|
||||
@@ -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
|
||||
```
|
||||
@@ -132,6 +146,80 @@ 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 Policies
|
||||
|
||||
API-created route records pass `metadata.sourcePolicy` alongside the SmartProxy route config to express ordered source and path policy variants without duplicating whole routes by hand. A source policy contains ordered `bindings`, each pointing 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-policy rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-policy binding and path-policy overrides.
|
||||
- A public fallback binding must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
|
||||
- Create/update paths reject source policies with missing source profiles, source profiles without source matches, missing final all-source fallback, or any all-source binding that shadows later bindings; persisted invalid policies 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, and 512 compiled SmartProxy route-port variants per stored route.
|
||||
|
||||
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: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
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
|
||||
@@ -196,6 +284,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.
|
||||
@@ -247,6 +348,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.
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -28,6 +29,40 @@ const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_Admin
|
||||
'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;
|
||||
@@ -38,42 +73,15 @@ tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
testDb = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
dbName,
|
||||
});
|
||||
await testDb.start();
|
||||
await testDb.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
const fakeDcRouter = {
|
||||
options: {
|
||||
opsServerPort: testPort,
|
||||
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: testDb,
|
||||
};
|
||||
|
||||
opsServer = new OpsServer(fakeDcRouter as any);
|
||||
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
|
||||
await opsServer.start();
|
||||
});
|
||||
|
||||
@@ -170,6 +178,30 @@ tap.test('authenticates the persisted admin locally by normalized email', async
|
||||
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 {
|
||||
@@ -233,6 +265,28 @@ tap.test('lists persisted users without password material', async () => {
|
||||
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();
|
||||
@@ -246,4 +300,49 @@ tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -56,6 +56,7 @@ const setupHandler = (scopes: TScope[], options?: {
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
validateIdentity: async () => null,
|
||||
adminIdentityGuard: {
|
||||
exec: async () => false,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
@@ -2,9 +2,21 @@ 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 * 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
// Define custom port mapping
|
||||
@@ -115,6 +127,7 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
});
|
||||
|
||||
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 +142,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
opsServerPort: 3104,
|
||||
opsServerPort,
|
||||
dbConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
@@ -32,6 +32,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 +43,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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+26
-7
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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 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 || {});
|
||||
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 || {});
|
||||
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 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');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { getOciContainerConfig } from '../ts_oci_container/index.js';
|
||||
|
||||
tap.test('OCI config should accept explicit DNS bind interface', async () => {
|
||||
const previousValue = process.env.DCROUTER_DNS_BIND_INTERFACE;
|
||||
process.env.DCROUTER_DNS_BIND_INTERFACE = '192.168.190.3';
|
||||
|
||||
try {
|
||||
const config = getOciContainerConfig();
|
||||
expect(config.dnsBindInterface).toEqual('192.168.190.3');
|
||||
} finally {
|
||||
if (previousValue === undefined) {
|
||||
delete process.env.DCROUTER_DNS_BIND_INTERFACE;
|
||||
} else {
|
||||
process.env.DCROUTER_DNS_BIND_INTERFACE = previousValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,126 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { requireOpsAuth } from '../ts/opsserver/helpers/auth.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
type TScope = interfaces.data.TApiTokenScope;
|
||||
|
||||
const makeIdentity = (role: string = 'user'): interfaces.data.IIdentity => ({
|
||||
jwt: `jwt-${role}`,
|
||||
userId: `${role}-user`,
|
||||
name: role,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
role,
|
||||
});
|
||||
|
||||
const makeOpsServer = (options: {
|
||||
identityRole?: string | null;
|
||||
tokenScopes?: TScope[];
|
||||
tokenPolicy?: interfaces.data.IApiTokenPolicy;
|
||||
}) => {
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'test-token',
|
||||
tokenHash: 'hash',
|
||||
scopes: options.tokenScopes || [],
|
||||
policy: options.tokenPolicy,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
createdBy: 'token-user',
|
||||
enabled: true,
|
||||
} as interfaces.data.IStoredApiToken;
|
||||
|
||||
return {
|
||||
adminHandler: {
|
||||
validateIdentity: async (identityArg?: interfaces.data.IIdentity) => {
|
||||
if (!identityArg || options.identityRole === null) return null;
|
||||
return { ...identityArg, role: options.identityRole || identityArg.role || 'user' };
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
apiTokenManager: {
|
||||
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
|
||||
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: TScope) => {
|
||||
if (storedTokenArg.policy?.role === 'admin') return true;
|
||||
return storedTokenArg.scopes.includes('*') || storedTokenArg.scopes.includes(scopeArg) || Boolean(storedTokenArg.policy?.scopes?.includes(scopeArg));
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const getErrorText = (errorArg: unknown) => {
|
||||
return (errorArg as any).errorText || (errorArg as any).text || (errorArg as Error).message;
|
||||
};
|
||||
|
||||
tap.test('requireOpsAuth accepts valid JWT identity for read endpoints', async () => {
|
||||
const auth = await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: 'user' }),
|
||||
{ identity: makeIdentity('user') },
|
||||
{ scope: 'config:read' },
|
||||
);
|
||||
expect(auth.type).toEqual('identity');
|
||||
expect(auth.userId).toEqual('user-user');
|
||||
expect(auth.isAdmin).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('requireOpsAuth rejects non-admin JWT identity for admin identity requirements', async () => {
|
||||
let errorText = '';
|
||||
try {
|
||||
await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: 'user' }),
|
||||
{ identity: makeIdentity('user') },
|
||||
{ scope: 'routes:write', requireAdminIdentity: true },
|
||||
);
|
||||
} catch (error) {
|
||||
errorText = getErrorText(error);
|
||||
}
|
||||
expect(errorText).toEqual('admin identity required');
|
||||
});
|
||||
|
||||
tap.test('requireOpsAuth accepts scoped API tokens', async () => {
|
||||
const auth = await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
|
||||
{ apiToken: 'valid-token' },
|
||||
{ scope: 'logs:read' },
|
||||
);
|
||||
expect(auth.type).toEqual('apiToken');
|
||||
expect(auth.userId).toEqual('token-user');
|
||||
});
|
||||
|
||||
tap.test('requireOpsAuth rejects API tokens without the required scope', async () => {
|
||||
let errorText = '';
|
||||
try {
|
||||
await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
|
||||
{ apiToken: 'valid-token' },
|
||||
{ scope: 'stats:read' },
|
||||
);
|
||||
} catch (error) {
|
||||
errorText = getErrorText(error);
|
||||
}
|
||||
expect(errorText).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
tap.test('requireOpsAuth requires admin policy for sensitive API-token operations', async () => {
|
||||
let errorText = '';
|
||||
try {
|
||||
await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: null, tokenScopes: ['tokens:manage'] }),
|
||||
{ apiToken: 'valid-token' },
|
||||
{ scope: 'tokens:manage', requireAdminToken: true },
|
||||
);
|
||||
} catch (error) {
|
||||
errorText = getErrorText(error);
|
||||
}
|
||||
expect(errorText).toEqual('admin API token required');
|
||||
|
||||
const auth = await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: null, tokenPolicy: { role: 'admin' } }),
|
||||
{ apiToken: 'valid-token' },
|
||||
{ scope: 'tokens:manage', requireAdminToken: true },
|
||||
);
|
||||
expect(auth.isAdmin).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ tap.test('should resolve source profile onto a route', async () => {
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should merge inline route security with profile security', async () => {
|
||||
tap.test('should replace inline route security when source profile is selected', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1'],
|
||||
@@ -102,13 +102,26 @@ tap.test('should merge inline route security with profile security', async () =>
|
||||
|
||||
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');
|
||||
expect(result.route.security!.ipAllowList!.includes('127.0.0.1')).toBeFalse();
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
// Inline maxConnections overrides profile
|
||||
expect(result.route.security!.maxConnections).toEqual(5000);
|
||||
tap.test('should remove stale wildcard security from a profile-backed route', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security!.ipAllowList!.includes('*')).toBeFalse();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('should deduplicate IP lists during merge', async () => {
|
||||
@@ -302,11 +315,22 @@ tap.test('should find routes by profile ref (sync)', async () => {
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
});
|
||||
storedRoutes.set('route-d', {
|
||||
id: 'route-d',
|
||||
route: makeRoute({ name: 'route-d' }),
|
||||
enabled: true,
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||
expect(profileRefs.length).toEqual(2);
|
||||
expect(profileRefs.length).toEqual(3);
|
||||
expect(profileRefs).toContain('route-a');
|
||||
expect(profileRefs).toContain('route-c');
|
||||
expect(profileRefs).toContain('route-d');
|
||||
|
||||
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||
expect(targetRefs.length).toEqual(2);
|
||||
@@ -314,6 +338,20 @@ tap.test('should find routes by profile ref (sync)', async () => {
|
||||
expect(targetRefs).toContain('route-c');
|
||||
});
|
||||
|
||||
tap.test('should resolve source policy binding display names', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.sourcePolicy!.bindings[0].sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-x', {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,869 @@
|
||||
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 = {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ 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();
|
||||
});
|
||||
|
||||
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 = {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
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(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
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(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
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(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
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 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(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'public' },
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(variants).toEqual([]);
|
||||
});
|
||||
|
||||
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 = {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public', pathPolicies }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(SourcePolicyCompiler.validateSourcePolicyShape(metadata.sourcePolicy)).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(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'empty-ai' },
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [{ 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 = 10000;
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
route,
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ 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('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: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ 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: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await manager.applyRoutes();
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
expect(appliedRoutes[0].length).toEqual(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: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ 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?.sourcePolicy?.bindings[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: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain("Source profile 'missing' not found");
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings).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: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ 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?.sourcePolicy?.bindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source policies without a final all-source 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).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('Source policy must end with an all-source fallback profile');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('public');
|
||||
});
|
||||
|
||||
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: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ 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: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('maxConnections');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[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: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
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?.sourcePolicy?.bindings[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: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
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?.sourcePolicy?.bindings[0].pathPolicies).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -2,6 +2,7 @@ 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' });
|
||||
@@ -76,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 () => {
|
||||
@@ -120,15 +121,15 @@ tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN client
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual([]);
|
||||
expect(prepared.security.ipBlockList).toContain('*');
|
||||
expect(prepared.security.ipAllowList).toEqual(['*']);
|
||||
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', async () => {
|
||||
tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
undefined,
|
||||
() => ['10.8.0.2'],
|
||||
() => ['client-1'],
|
||||
);
|
||||
const route = {
|
||||
name: 'private-route',
|
||||
@@ -143,8 +144,301 @@ tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', as
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual(['10.8.0.2']);
|
||||
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 () => {
|
||||
|
||||
@@ -136,6 +136,9 @@ const setupHandler = (options: {
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
validateIdentity: async (identity: interfaces.data.IIdentity) => options.isAdmin
|
||||
? { ...identity, role: 'admin' }
|
||||
: identity,
|
||||
adminIdentityGuard: {
|
||||
exec: async () => Boolean(options.isAdmin),
|
||||
},
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.31.0',
|
||||
version: '13.42.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+362
-104
@@ -26,13 +26,14 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
|
||||
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig } from '../ts_interfaces/data/remoteingress.js';
|
||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
@@ -93,6 +94,9 @@ export interface IDcRouterOptions {
|
||||
* Email domains with `internal-dns` mode must be included here
|
||||
*/
|
||||
dnsScopes?: string[];
|
||||
|
||||
/** Explicit UDP bind address for the embedded DNS server. Defaults to auto-detection. */
|
||||
dnsBindInterface?: string;
|
||||
|
||||
/**
|
||||
* IPs of proxies that forward traffic to your server (optional)
|
||||
@@ -277,6 +281,9 @@ export class DcRouter {
|
||||
// Remote Ingress
|
||||
public remoteIngressManager?: RemoteIngressManager;
|
||||
public tunnelManager?: TunnelManager;
|
||||
private remoteIngressHubLifecycleChain: Promise<void> = Promise.resolve();
|
||||
private remoteIngressHubStopping = false;
|
||||
private remoteIngressHubGeneration = 0;
|
||||
|
||||
// VPN
|
||||
public vpnManager?: VpnManager;
|
||||
@@ -323,6 +330,11 @@ export class DcRouter {
|
||||
public serviceManager: plugins.taskbuffer.ServiceManager;
|
||||
private serviceSubjectSubscription?: plugins.smartrx.rxjs.Subscription;
|
||||
public smartAcmeReady = false;
|
||||
private smartAcmeServiceStarted = false;
|
||||
private smartAcmeStartGeneration = 0;
|
||||
private smartAcmeStartPromise?: Promise<void>;
|
||||
private smartAcmeRetryTimer?: ReturnType<typeof setTimeout>;
|
||||
private smartAcmeRetryAttempt = 0;
|
||||
|
||||
// TypedRouter for API endpoints
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -542,45 +554,14 @@ export class DcRouter {
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.withStart(async () => {
|
||||
if (this.smartAcme) {
|
||||
await this.smartAcme.start();
|
||||
this.smartAcmeReady = true;
|
||||
logger.log('info', 'SmartAcme DNS-01 provider is now ready');
|
||||
|
||||
// Re-trigger certificate provisioning for all auto-cert routes.
|
||||
// During startup, certProvisionFunction returned 'http01' (SmartAcme not ready),
|
||||
// but Rust ACME is disabled when certProvisionFunction is set — so all domains
|
||||
// failed silently (SmartProxy doesn't emit certificate-failed for this path).
|
||||
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
|
||||
// which calls certProvisionFunction again — now with smartAcmeReady === true.
|
||||
if (this.routeConfigManager) {
|
||||
// Go through RouteConfigManager to get the full merged route set
|
||||
// and serialize via the route-update mutex (prevents stale overwrites)
|
||||
logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
|
||||
this.routeConfigManager.applyRoutes().catch((err: any) => {
|
||||
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
|
||||
});
|
||||
} else if (this.smartProxy) {
|
||||
// No RouteConfigManager (DB disabled) — re-send current routes to trigger cert provisioning
|
||||
if (this.certProvisionScheduler) {
|
||||
this.certProvisionScheduler.clear();
|
||||
}
|
||||
const currentRoutes = this.smartProxy.routeManager.getRoutes();
|
||||
logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
|
||||
this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
|
||||
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.smartAcmeServiceStarted = true;
|
||||
this.startSmartAcmeInBackground();
|
||||
})
|
||||
.withStop(async () => {
|
||||
this.smartAcmeReady = false;
|
||||
if (this.smartAcme) {
|
||||
await this.smartAcme.stop();
|
||||
this.smartAcme = undefined;
|
||||
}
|
||||
this.smartAcmeServiceStarted = false;
|
||||
await this.stopSmartAcme();
|
||||
})
|
||||
.withRetry({ maxRetries: 20, baseDelayMs: 5000, maxDelayMs: 3_600_000, backoffFactor: 2 }),
|
||||
.withRetry({ maxRetries: 0 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -605,20 +586,15 @@ export class DcRouter {
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.createVpnRouteAllowListResolver(),
|
||||
this.createVpnClientAccessResolver(),
|
||||
this.referenceResolver,
|
||||
// Sync routes to RemoteIngressManager whenever routes change,
|
||||
// then push updated derived ports to the Rust hub binary
|
||||
async (routes) => {
|
||||
if (this.remoteIngressManager) {
|
||||
this.remoteIngressManager.setRoutes(routes as any[]);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
try {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
} catch (err: unknown) {
|
||||
logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
|
||||
}
|
||||
try {
|
||||
await this.updateRemoteIngressRoutes(routes as IDcRouterRouteConfig[]);
|
||||
} catch (err: unknown) {
|
||||
logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
@@ -736,11 +712,7 @@ export class DcRouter {
|
||||
await this.setupRemoteIngress();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.tunnelManager) {
|
||||
await this.tunnelManager.stop();
|
||||
this.tunnelManager = undefined;
|
||||
}
|
||||
this.remoteIngressManager = undefined;
|
||||
await this.stopRemoteIngress();
|
||||
})
|
||||
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
|
||||
);
|
||||
@@ -780,6 +752,138 @@ export class DcRouter {
|
||||
});
|
||||
}
|
||||
|
||||
private startSmartAcmeInBackground(): void {
|
||||
if (!this.smartAcme) {
|
||||
this.smartAcmeReady = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = ++this.smartAcmeStartGeneration;
|
||||
this.smartAcmeReady = false;
|
||||
this.smartAcmeRetryAttempt = 0;
|
||||
this.clearSmartAcmeRetryTimer();
|
||||
this.scheduleSmartAcmeStart(generation, 0);
|
||||
}
|
||||
|
||||
private scheduleSmartAcmeStart(generation: number, delayMs: number): void {
|
||||
this.clearSmartAcmeRetryTimer();
|
||||
const retryTimer = setTimeout(() => {
|
||||
this.smartAcmeRetryTimer = undefined;
|
||||
this.runSmartAcmeStartAttempt(generation).catch((err) => {
|
||||
logger.log('error', `Unexpected SmartAcme startup error: ${(err as Error).message}`);
|
||||
});
|
||||
}, delayMs);
|
||||
this.smartAcmeRetryTimer = retryTimer;
|
||||
const unrefableTimer = retryTimer as any;
|
||||
if (typeof unrefableTimer?.unref === 'function') {
|
||||
unrefableTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
private async runSmartAcmeStartAttempt(generation: number): Promise<void> {
|
||||
const smartAcme = this.smartAcme;
|
||||
if (!smartAcme || generation !== this.smartAcmeStartGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startPromise = smartAcme.start();
|
||||
this.smartAcmeStartPromise = startPromise;
|
||||
|
||||
try {
|
||||
await startPromise;
|
||||
if (generation !== this.smartAcmeStartGeneration || this.smartAcme !== smartAcme) {
|
||||
await smartAcme.stop().catch((err) => {
|
||||
logger.log('warn', `Failed to stop stale SmartAcme instance: ${(err as Error).message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.smartAcmeReady = true;
|
||||
this.smartAcmeRetryAttempt = 0;
|
||||
logger.log('info', 'SmartAcme DNS-01 provider is now ready');
|
||||
this.retriggerCertificateProvisioningAfterSmartAcmeReady();
|
||||
} catch (err) {
|
||||
if (generation !== this.smartAcmeStartGeneration || this.smartAcme !== smartAcme) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.smartAcmeReady = false;
|
||||
await smartAcme.stop().catch((stopErr) => {
|
||||
logger.log('warn', `Failed to clean up SmartAcme after startup failure: ${(stopErr as Error).message}`);
|
||||
});
|
||||
this.smartAcmeRetryAttempt++;
|
||||
if (this.smartAcmeRetryAttempt > 20) {
|
||||
logger.log('error', `SmartAcme DNS-01 provider failed after 20 startup attempts: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDelayMs = 5000;
|
||||
const maxDelayMs = 3_600_000;
|
||||
const delayMs = Math.min(baseDelayMs * Math.pow(2, this.smartAcmeRetryAttempt - 1), maxDelayMs);
|
||||
const jitter = 0.8 + Math.random() * 0.4;
|
||||
const actualDelayMs = Math.floor(delayMs * jitter);
|
||||
logger.log('warn', `SmartAcme DNS-01 provider startup failed: ${(err as Error).message}; retrying in ${actualDelayMs}ms (attempt ${this.smartAcmeRetryAttempt}/20)`);
|
||||
this.scheduleSmartAcmeStart(generation, actualDelayMs);
|
||||
} finally {
|
||||
if (this.smartAcmeStartPromise === startPromise) {
|
||||
this.smartAcmeStartPromise = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private retriggerCertificateProvisioningAfterSmartAcmeReady(): void {
|
||||
// During startup, certProvisionFunction returns 'http01' while SmartAcme is not ready,
|
||||
// but Rust ACME is disabled when certProvisionFunction is set. Re-applying routes
|
||||
// retries provisioning now that DNS-01 is available.
|
||||
if (this.routeConfigManager) {
|
||||
logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
|
||||
this.routeConfigManager.applyRoutes().catch((err: any) => {
|
||||
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.smartProxy) {
|
||||
if (this.certProvisionScheduler) {
|
||||
this.certProvisionScheduler.clear();
|
||||
}
|
||||
const currentRoutes = this.smartProxy.routeManager.getRoutes();
|
||||
logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
|
||||
this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
|
||||
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearSmartAcmeRetryTimer(): void {
|
||||
if (this.smartAcmeRetryTimer) {
|
||||
clearTimeout(this.smartAcmeRetryTimer);
|
||||
this.smartAcmeRetryTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async stopSmartAcme(): Promise<void> {
|
||||
this.smartAcmeStartGeneration++;
|
||||
this.smartAcmeReady = false;
|
||||
this.smartAcmeRetryAttempt = 0;
|
||||
this.clearSmartAcmeRetryTimer();
|
||||
|
||||
const smartAcme = this.smartAcme;
|
||||
if (!smartAcme) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await smartAcme.stop();
|
||||
} catch (err) {
|
||||
logger.log('error', 'Error stopping SmartAcme', { error: String(err) });
|
||||
} finally {
|
||||
if (this.smartAcme === smartAcme) {
|
||||
this.smartAcme = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async start() {
|
||||
await this.checkSystemLimits();
|
||||
logger.log('info', 'Starting DcRouter Services');
|
||||
@@ -1095,17 +1199,13 @@ export class DcRouter {
|
||||
// Initialize cert provision scheduler
|
||||
this.certProvisionScheduler = new CertProvisionScheduler();
|
||||
|
||||
// If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction
|
||||
// Note: SmartAcme.start() is NOT called here — it runs as a separate optional service
|
||||
// via the ServiceManager, with aggressive retry for rate-limit resilience.
|
||||
// If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction.
|
||||
// SmartAcme starts in the background because ACME account setup can be slow or rate-limited,
|
||||
// and must not block dcrouter's global startup timeout.
|
||||
if (this.smartAcme) {
|
||||
await this.stopSmartAcme();
|
||||
}
|
||||
if (challengeHandlers.length > 0) {
|
||||
// Stop old SmartAcme if it exists (e.g., during updateSmartProxyConfig)
|
||||
if (this.smartAcme) {
|
||||
this.smartAcmeReady = false;
|
||||
await this.smartAcme.stop().catch(err =>
|
||||
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
|
||||
);
|
||||
}
|
||||
// Safe non-null: challengeHandlers.length > 0 implies both dnsManager
|
||||
// and acmeConfig exist (enforced above).
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
@@ -1115,6 +1215,9 @@ export class DcRouter {
|
||||
challengeHandlers: challengeHandlers,
|
||||
challengePriority: ['dns-01'],
|
||||
});
|
||||
if (this.smartAcmeServiceStarted) {
|
||||
this.startSmartAcmeInBackground();
|
||||
}
|
||||
|
||||
const scheduler = this.certProvisionScheduler;
|
||||
smartProxyConfig.certProvisionFallbackToAcme = false;
|
||||
@@ -1316,12 +1419,15 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
const firewallConfig = await this.securityPolicyManager.compileRemoteIngressFirewall();
|
||||
if (this.remoteIngressManager) {
|
||||
(this.remoteIngressManager as any).setFirewallConfig?.(firewallConfig);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
await this.queueRemoteIngressHubTask(async () => {
|
||||
if (this.remoteIngressHubStopping) return;
|
||||
if (this.remoteIngressManager) {
|
||||
this.remoteIngressManager.setFirewallConfig(firewallConfig);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private mergeSecurityPolicies(
|
||||
@@ -1875,16 +1981,21 @@ export class DcRouter {
|
||||
logger.log('info', `Setting up DNS server with primary nameserver: ${primaryNameserver}`);
|
||||
|
||||
// Get VM IP address for UDP binding
|
||||
const networkInterfaces = plugins.os.networkInterfaces();
|
||||
let vmIpAddress = '0.0.0.0'; // Default to all interfaces
|
||||
|
||||
// Try to find the VM's internal IP address
|
||||
for (const [_name, interfaces] of Object.entries(networkInterfaces)) {
|
||||
if (interfaces) {
|
||||
for (const iface of interfaces) {
|
||||
if (!iface.internal && iface.family === 'IPv4') {
|
||||
vmIpAddress = iface.address;
|
||||
break;
|
||||
const networkInterfaces = plugins.os.networkInterfaces() as Record<
|
||||
string,
|
||||
Array<{ internal: boolean; family: string; address: string }> | undefined
|
||||
>;
|
||||
let vmIpAddress = this.options.dnsBindInterface || '0.0.0.0'; // Default to all interfaces
|
||||
|
||||
// Try to find the VM's internal IP address when no explicit bind address is configured.
|
||||
if (!this.options.dnsBindInterface) {
|
||||
interfaceLoop: for (const [_name, interfaces] of Object.entries(networkInterfaces)) {
|
||||
if (interfaces) {
|
||||
for (const iface of interfaces) {
|
||||
if (!iface.internal && iface.family === 'IPv4') {
|
||||
vmIpAddress = iface.address;
|
||||
break interfaceLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2332,28 +2443,180 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
logger.log('info', 'Setting up Remote Ingress hub...');
|
||||
this.remoteIngressHubStopping = false;
|
||||
const generation = ++this.remoteIngressHubGeneration;
|
||||
|
||||
// Initialize the edge registration manager
|
||||
this.remoteIngressManager = new RemoteIngressManager();
|
||||
await this.remoteIngressManager.initialize();
|
||||
this.remoteIngressManager.setFirewallConfig(
|
||||
await this.securityPolicyManager?.compileRemoteIngressFirewall(),
|
||||
);
|
||||
const remoteIngressManager = new RemoteIngressManager(this.options.remoteIngressConfig.performance);
|
||||
this.remoteIngressManager = remoteIngressManager;
|
||||
await remoteIngressManager.initialize();
|
||||
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firewallConfig = await this.securityPolicyManager?.compileRemoteIngressFirewall();
|
||||
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
|
||||
return;
|
||||
}
|
||||
remoteIngressManager.setFirewallConfig(firewallConfig);
|
||||
|
||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||
// will push the complete merged routes here.
|
||||
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
|
||||
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
||||
remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
||||
|
||||
// If ConfigManagers finished before us, re-apply routes
|
||||
// so the callback delivers the full DB set to our newly-created remoteIngressManager.
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.applyRoutes();
|
||||
}
|
||||
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
|
||||
await this.queueRemoteIngressHubTask(async () => {
|
||||
await this.startRemoteIngressTunnelHubLocked(generation);
|
||||
});
|
||||
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const edgeCount = remoteIngressManager.getAllEdges().length;
|
||||
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
|
||||
}
|
||||
|
||||
private isRemoteIngressHubGenerationCurrent(generation: number, manager: RemoteIngressManager): boolean {
|
||||
return !this.remoteIngressHubStopping
|
||||
&& generation === this.remoteIngressHubGeneration
|
||||
&& this.remoteIngressManager === manager;
|
||||
}
|
||||
|
||||
private queueRemoteIngressHubTask<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.remoteIngressHubLifecycleChain.then(task);
|
||||
this.remoteIngressHubLifecycleChain = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async stopRemoteIngress(): Promise<void> {
|
||||
this.remoteIngressHubStopping = true;
|
||||
this.remoteIngressHubGeneration++;
|
||||
await this.queueRemoteIngressHubTask(async () => {
|
||||
const currentTunnelManager = this.tunnelManager;
|
||||
this.tunnelManager = undefined;
|
||||
if (currentTunnelManager) {
|
||||
await currentTunnelManager.stop();
|
||||
}
|
||||
});
|
||||
this.remoteIngressManager = undefined;
|
||||
}
|
||||
|
||||
public async mutateRemoteIngressEdges<T>(
|
||||
mutation: (manager: RemoteIngressManager) => Promise<T>,
|
||||
syncAllowedEdges = true,
|
||||
): Promise<T> {
|
||||
return await this.queueRemoteIngressHubTask(async () => {
|
||||
if (this.remoteIngressHubStopping) {
|
||||
throw new Error('RemoteIngress is stopping');
|
||||
}
|
||||
const manager = this.remoteIngressManager;
|
||||
if (!manager) {
|
||||
throw new Error('RemoteIngress not configured');
|
||||
}
|
||||
const result = await mutation(manager);
|
||||
if (syncAllowedEdges && this.tunnelManager) {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async updateRemoteIngressRoutes(routes: IDcRouterRouteConfig[]): Promise<void> {
|
||||
await this.queueRemoteIngressHubTask(async () => {
|
||||
if (this.remoteIngressHubStopping) return;
|
||||
if (this.remoteIngressManager) {
|
||||
this.remoteIngressManager.setRoutes(routes);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async updateRemoteIngressHubSettings(
|
||||
updates: { performance?: IRemoteIngressPerformanceConfig },
|
||||
updatedBy: string,
|
||||
): Promise<IRemoteIngressHubSettings> {
|
||||
return await this.queueRemoteIngressHubTask(async () => {
|
||||
if (this.remoteIngressHubStopping) {
|
||||
throw new Error('RemoteIngress is stopping');
|
||||
}
|
||||
if (!this.remoteIngressManager) {
|
||||
throw new Error('RemoteIngress is not configured');
|
||||
}
|
||||
|
||||
const settings = await this.remoteIngressManager.updateHubSettings(updates, updatedBy);
|
||||
if (this.options.remoteIngressConfig?.enabled) {
|
||||
await this.restartRemoteIngressTunnelHubLocked();
|
||||
}
|
||||
return settings;
|
||||
});
|
||||
}
|
||||
|
||||
private async restartRemoteIngressTunnelHubLocked(): Promise<void> {
|
||||
const generation = ++this.remoteIngressHubGeneration;
|
||||
if (!this.remoteIngressManager || !this.options.remoteIngressConfig?.enabled || this.remoteIngressHubStopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTunnelManager = this.tunnelManager;
|
||||
this.tunnelManager = undefined;
|
||||
if (currentTunnelManager) {
|
||||
await currentTunnelManager.stop();
|
||||
}
|
||||
|
||||
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
|
||||
return;
|
||||
}
|
||||
await this.startRemoteIngressTunnelHubLocked(generation);
|
||||
}
|
||||
|
||||
private async startRemoteIngressTunnelHubLocked(generation: number): Promise<void> {
|
||||
const riCfg = this.options.remoteIngressConfig;
|
||||
const manager = this.remoteIngressManager;
|
||||
if (!riCfg?.enabled || !manager || this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tlsConfig = await this.resolveRemoteIngressTlsConfig(riCfg);
|
||||
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tunnelManager = new TunnelManager(manager, {
|
||||
tunnelPort: riCfg.tunnelPort ?? 8443,
|
||||
targetHost: '127.0.0.1',
|
||||
tls: tlsConfig,
|
||||
performance: manager.getHubPerformanceConfig(),
|
||||
});
|
||||
try {
|
||||
await tunnelManager.start();
|
||||
} catch (err) {
|
||||
await tunnelManager.stop().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
|
||||
await tunnelManager.stop();
|
||||
return;
|
||||
}
|
||||
this.tunnelManager = tunnelManager;
|
||||
}
|
||||
|
||||
private async resolveRemoteIngressTlsConfig(
|
||||
riCfg: NonNullable<IDcRouterOptions['remoteIngressConfig']>,
|
||||
): Promise<{ certPem: string; keyPem: string } | undefined> {
|
||||
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
|
||||
let tlsConfig: { certPem: string; keyPem: string } | undefined;
|
||||
|
||||
// Priority 1: Explicit cert/key file paths
|
||||
@@ -2383,26 +2646,16 @@ export class DcRouter {
|
||||
logger.log('info', 'No TLS cert configured for RemoteIngress tunnel — using auto-generated self-signed');
|
||||
}
|
||||
|
||||
// Create and start the tunnel manager
|
||||
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
|
||||
tunnelPort: riCfg.tunnelPort ?? 8443,
|
||||
targetHost: '127.0.0.1',
|
||||
tls: tlsConfig,
|
||||
performance: riCfg.performance,
|
||||
});
|
||||
await this.tunnelManager.start();
|
||||
|
||||
const edgeCount = this.remoteIngressManager.getAllEdges().length;
|
||||
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
|
||||
return tlsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up VPN server for VPN-based route access control.
|
||||
*/
|
||||
private createVpnRouteAllowListResolver(): ((
|
||||
private createVpnClientAccessResolver(): ((
|
||||
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
|
||||
routeId?: string,
|
||||
) => TIpAllowEntry[]) | undefined {
|
||||
) => TVpnClientAllowEntry[]) | undefined {
|
||||
if (!this.options.vpnConfig?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -2416,7 +2669,7 @@ export class DcRouter {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
return this.targetProfileManager.getMatchingVpnClients(
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
@@ -2452,17 +2705,21 @@ export class DcRouter {
|
||||
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
|
||||
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||
onClientChanged: () => {
|
||||
// Re-apply routes so profile-based ipAllowLists get updated
|
||||
// Re-apply routes so profile-based VPN client grants get updated
|
||||
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
|
||||
this.routeConfigManager?.applyRoutes().catch((err) => {
|
||||
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
|
||||
});
|
||||
},
|
||||
onClientSourceIpsChanged: () => {
|
||||
// SmartProxy now receives the real source IP per connection via PROXY v2.
|
||||
// Source-IP changes are reflected in status/UI only; route config is static.
|
||||
},
|
||||
getClientDirectTargets: (targetProfileIds: string[]) => {
|
||||
if (!this.targetProfileManager) return [];
|
||||
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
|
||||
},
|
||||
getClientAllowedIPs: async (targetProfileIds: string[]) => {
|
||||
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, _sourceIp?: string) => {
|
||||
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||
const ips = new Set<string>([subnet]);
|
||||
|
||||
@@ -2471,7 +2728,8 @@ export class DcRouter {
|
||||
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
|
||||
|
||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||
targetProfileIds, allRoutes,
|
||||
targetProfileIds,
|
||||
allRoutes,
|
||||
);
|
||||
|
||||
// Add target IPs directly
|
||||
@@ -2498,7 +2756,7 @@ export class DcRouter {
|
||||
await this.vpnManager.start();
|
||||
|
||||
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
|
||||
// get correct profile-based ipAllowLists
|
||||
// get correct profile-based VPN client grants.
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
@@ -2594,7 +2852,7 @@ export class DcRouter {
|
||||
this.options.vpnConfig = config;
|
||||
this.vpnDomainIpCache.clear();
|
||||
this.warnedWildcardVpnDomains.clear();
|
||||
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver());
|
||||
this.routeConfigManager?.setVpnClientAccessResolver(this.createVpnClientAccessResolver());
|
||||
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
await this.setupVpnServer();
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
IRouteMetadata,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
IRouteSourcePolicy,
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
@@ -281,6 +289,7 @@ export class ReferenceResolver {
|
||||
/**
|
||||
* Resolve references for a single route.
|
||||
* Materializes source profile and/or network target into the route's fields.
|
||||
* When a source profile is selected, it owns the route security fully.
|
||||
* Returns the resolved route and updated metadata.
|
||||
*/
|
||||
public resolveRoute(
|
||||
@@ -289,14 +298,21 @@ export class ReferenceResolver {
|
||||
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||
|
||||
if (resolvedMetadata.sourceProfileRef) {
|
||||
if (resolvedMetadata.sourcePolicy?.bindings.length) {
|
||||
const resolvedSourcePolicy = this.resolveRouteSourcePolicy(resolvedMetadata.sourcePolicy);
|
||||
if (resolvedSourcePolicy) {
|
||||
resolvedMetadata.sourcePolicy = resolvedSourcePolicy;
|
||||
resolvedMetadata.sourceProfileRef = undefined;
|
||||
resolvedMetadata.sourceProfileName = undefined;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
}
|
||||
} else 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),
|
||||
security: this.cloneSecurityFields(resolvedSecurity),
|
||||
};
|
||||
resolvedMetadata.sourceProfileName = profile?.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
@@ -336,7 +352,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 +366,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 +387,41 @@ export class ReferenceResolver {
|
||||
// Private: source profile resolution with inheritance
|
||||
// =========================================================================
|
||||
|
||||
private resolveRouteSourcePolicy(sourcePolicy: IRouteSourcePolicy): IRouteSourcePolicy | undefined {
|
||||
const bindings = sourcePolicy.bindings
|
||||
.map((binding) => {
|
||||
const profile = this.profiles.get(binding.sourceProfileRef);
|
||||
if (!profile) {
|
||||
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source policy 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>();
|
||||
if (metadata?.sourceProfileRef) {
|
||||
refs.add(metadata.sourceProfileRef);
|
||||
}
|
||||
for (const binding of metadata?.sourcePolicy?.bindings || []) {
|
||||
if (binding.sourceProfileRef) {
|
||||
refs.add(binding.sourceProfileRef);
|
||||
}
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
private resolveSourceProfile(
|
||||
profileId: string,
|
||||
visited: Set<string> = new Set(),
|
||||
@@ -445,10 +496,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 +601,50 @@ 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 sourcePolicy = metadata.sourcePolicy?.bindings?.length
|
||||
? {
|
||||
bindings: metadata.sourcePolicy.bindings.filter(
|
||||
(binding) => binding.sourceProfileRef !== profileId,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const nextMetadata: IRouteMetadata = {
|
||||
...metadata,
|
||||
sourceProfileRef: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileRef,
|
||||
sourceProfileName: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileName,
|
||||
sourcePolicy: sourcePolicy?.bindings.length ? sourcePolicy : undefined,
|
||||
};
|
||||
|
||||
if (!nextMetadata.sourceProfileRef && !nextMetadata.sourcePolicy && !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);
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
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 {
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
IRoutePathPolicyBinding,
|
||||
IRouteSourcePolicy,
|
||||
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';
|
||||
|
||||
/** 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,7 +61,7 @@ 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[],
|
||||
@@ -73,10 +77,10 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +136,10 @@ export class RouteConfigManager {
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(metadata?.sourcePolicy);
|
||||
if (sourcePolicyPayloadError) {
|
||||
throw new Error(sourcePolicyPayloadError);
|
||||
}
|
||||
|
||||
// Ensure route has a name
|
||||
if (!route.name) {
|
||||
@@ -145,6 +153,10 @@ export class RouteConfigManager {
|
||||
route = resolved.route;
|
||||
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
const sourcePolicyValidationError = this.validateSourcePolicy(resolvedMetadata?.sourcePolicy, route);
|
||||
if (sourcePolicyValidationError) {
|
||||
throw new Error(sourcePolicyValidationError);
|
||||
}
|
||||
|
||||
const stored: IRoute = {
|
||||
id,
|
||||
@@ -175,6 +187,15 @@ export class RouteConfigManager {
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(patch.metadata?.sourcePolicy);
|
||||
if (sourcePolicyPayloadError) {
|
||||
return { success: false, message: sourcePolicyPayloadError };
|
||||
}
|
||||
|
||||
const previousSourceProfileRef = stored.metadata?.sourceProfileRef;
|
||||
const previousRoute = structuredClone(stored.route);
|
||||
const previousMetadata = structuredClone(stored.metadata);
|
||||
const previousEnabled = stored.enabled;
|
||||
|
||||
const isToggleOnlyPatch = patch.enabled !== undefined
|
||||
&& patch.route === undefined
|
||||
@@ -217,6 +238,13 @@ export class RouteConfigManager {
|
||||
...stored.metadata,
|
||||
...patch.metadata,
|
||||
});
|
||||
if (
|
||||
previousSourceProfileRef
|
||||
&& !stored.metadata?.sourceProfileRef
|
||||
&& !patch.route?.security
|
||||
) {
|
||||
delete stored.route.security;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve if metadata refs exist and resolver is available
|
||||
@@ -226,6 +254,14 @@ export class RouteConfigManager {
|
||||
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
|
||||
const sourcePolicyValidationError = this.validateSourcePolicy(stored.metadata?.sourcePolicy, stored.route);
|
||||
if (sourcePolicyValidationError) {
|
||||
stored.route = previousRoute;
|
||||
stored.metadata = previousMetadata;
|
||||
stored.enabled = previousEnabled;
|
||||
return { success: false, message: sourcePolicyValidationError };
|
||||
}
|
||||
|
||||
stored.updatedAt = Date.now();
|
||||
|
||||
await this.persistRoute(stored);
|
||||
@@ -446,6 +482,7 @@ export class RouteConfigManager {
|
||||
|
||||
const normalized: IRouteMetadata = {
|
||||
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
|
||||
sourcePolicy: this.normalizeSourcePolicy(metadata.sourcePolicy),
|
||||
networkTargetRef: normalizeString(metadata.networkTargetRef),
|
||||
sourceProfileName: normalizeString(metadata.sourceProfileName),
|
||||
networkTargetName: normalizeString(metadata.networkTargetName),
|
||||
@@ -474,7 +511,7 @@ export class RouteConfigManager {
|
||||
if (!normalized.networkTargetRef) {
|
||||
normalized.networkTargetName = undefined;
|
||||
}
|
||||
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
||||
if (!normalized.sourceProfileRef && !normalized.sourcePolicy && !normalized.networkTargetRef) {
|
||||
normalized.lastResolvedAt = undefined;
|
||||
}
|
||||
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
||||
@@ -499,6 +536,128 @@ export class RouteConfigManager {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeSourcePolicy(sourcePolicy?: Partial<IRouteSourcePolicy>): IRouteSourcePolicy | undefined {
|
||||
const bindings = sourcePolicy?.bindings;
|
||||
if (!Array.isArray(bindings)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedBindings: IRouteSourcePolicy['bindings'] = [];
|
||||
for (const binding of bindings) {
|
||||
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 ? { bindings: 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 validateSourcePolicy(
|
||||
sourcePolicy: IRouteSourcePolicy | undefined,
|
||||
route: IDcRouterRouteConfig,
|
||||
): string | undefined {
|
||||
const shapeError = SourcePolicyCompiler.validateSourcePolicyShape(sourcePolicy, route);
|
||||
if (shapeError) {
|
||||
return shapeError;
|
||||
}
|
||||
return SourcePolicyCompiler.validateResolvedSourcePolicy(sourcePolicy, 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
|
||||
// =========================================================================
|
||||
@@ -561,10 +720,10 @@ export class RouteConfigManager {
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||
// 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.prepareStoredRouteForApply(route));
|
||||
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,9 +743,15 @@ export class RouteConfigManager {
|
||||
});
|
||||
}
|
||||
|
||||
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
|
||||
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
|
||||
const sourcePolicyRoutes = SourcePolicyCompiler.compileRoute(
|
||||
hydratedRoute || storedRoute.route,
|
||||
storedRoute.metadata,
|
||||
this.referenceResolver,
|
||||
storedRoute.id,
|
||||
);
|
||||
return sourcePolicyRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
|
||||
}
|
||||
|
||||
private prepareRouteForApply(
|
||||
@@ -608,21 +773,47 @@ export class RouteConfigManager {
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
|
||||
|
||||
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
|
||||
const existingBlockList = route.security?.ipBlockList || [];
|
||||
const ipBlockList = vpnEntries.length
|
||||
? existingBlockList
|
||||
: [...new Set([...existingBlockList, '*'])];
|
||||
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
|
||||
return route;
|
||||
}
|
||||
|
||||
const existingVpnSecurity = route.security?.vpn || {};
|
||||
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
|
||||
existingVpnSecurity.allowedClients || [],
|
||||
vpnEntries,
|
||||
);
|
||||
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: vpnEntries,
|
||||
ipBlockList,
|
||||
vpn: {
|
||||
...existingVpnSecurity,
|
||||
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
|
||||
allowedClients: mergedAllowedClients,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mergeVpnClientAllowEntries(
|
||||
existingEntries: TVpnClientAllowEntry[],
|
||||
vpnEntries: TVpnClientAllowEntry[],
|
||||
): TVpnClientAllowEntry[] {
|
||||
const merged: TVpnClientAllowEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of [...existingEntries, ...vpnEntries]) {
|
||||
const key = typeof entry === 'string'
|
||||
? `client:${entry}`
|
||||
: `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(entry);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import {
|
||||
giteaRoutePathClassLabels,
|
||||
giteaRoutePathClassPatterns,
|
||||
routePathClasses,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type {
|
||||
IRoutePathPolicyBinding,
|
||||
IRouteMetadata,
|
||||
IRouteSecurity,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSourcePolicyBinding,
|
||||
} 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?.sourcePolicy?.bindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return [route];
|
||||
}
|
||||
if (this.validateSourcePolicyShape(metadata?.sourcePolicy, route)) {
|
||||
return [];
|
||||
}
|
||||
if (!referenceResolver) {
|
||||
return [];
|
||||
}
|
||||
if (this.validateResolvedSourcePolicy(metadata?.sourcePolicy, referenceResolver)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const compiledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const basePriority = route.priority ?? 0;
|
||||
|
||||
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;
|
||||
}
|
||||
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,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return compiledRoutes;
|
||||
}
|
||||
|
||||
public static validateSourcePolicyPayload(sourcePolicy?: Partial<IRouteSourcePolicy>): string | undefined {
|
||||
if (!sourcePolicy) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(sourcePolicy.bindings)) {
|
||||
return 'Source policy bindings must be an array';
|
||||
}
|
||||
if (sourcePolicy.bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (sourcePolicy.bindings.length > sourcePolicyLimits.maxBindings) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
|
||||
}
|
||||
|
||||
const validClasses = new Set<string>(routePathClasses);
|
||||
for (const binding of sourcePolicy.bindings) {
|
||||
if (!binding || typeof binding !== 'object') {
|
||||
return 'Source policy binding must be an object';
|
||||
}
|
||||
if (typeof binding.sourceProfileRef !== 'string') {
|
||||
return 'Source policy binding requires a source profile';
|
||||
}
|
||||
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
|
||||
return `Source policy source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
|
||||
}
|
||||
if (binding.sourceProfileRef.trim().length === 0) {
|
||||
return 'Source policy binding requires a source profile';
|
||||
}
|
||||
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
|
||||
return `Source policy 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(
|
||||
sourcePolicy?: IRouteSourcePolicy,
|
||||
route?: plugins.smartproxy.IRouteConfig,
|
||||
): string | undefined {
|
||||
const payloadError = this.validateSourcePolicyPayload(sourcePolicy);
|
||||
if (payloadError) {
|
||||
return payloadError;
|
||||
}
|
||||
const bindings = sourcePolicy?.bindings || [];
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
const expandedPortCount = route ? this.getExpandedPortCount(route.match?.ports) : 1;
|
||||
if (estimatedCompiledRoutes * expandedPortCount > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route-port variants`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static validateResolvedSourcePolicy(
|
||||
sourcePolicy: IRouteSourcePolicy | undefined,
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
): string | undefined {
|
||||
const bindings = sourcePolicy?.bindings || [];
|
||||
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 a source policy';
|
||||
}
|
||||
if (index === bindings.length - 1 && !matchesAllSources) {
|
||||
return 'Source policy must end with an all-source fallback profile';
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static buildCompiledRoute(options: {
|
||||
route: plugins.smartproxy.IRouteConfig;
|
||||
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
|
||||
profileName: string;
|
||||
profileSecurity: IRouteSecurity;
|
||||
binding: IRouteSourcePolicyBinding;
|
||||
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 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 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: IRouteSourcePolicyBinding,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -4,5 +4,6 @@ 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 { DbSeeder } from './classes.db-seeder.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
|
||||
@@ -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,29 @@
|
||||
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 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;
|
||||
|
||||
|
||||
@@ -24,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';
|
||||
|
||||
+25
-8
@@ -209,9 +209,9 @@ export class DnsManager {
|
||||
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
|
||||
if (!this.dnsServer) return;
|
||||
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
|
||||
if (question.name === rec.name && question.type === rec.type) {
|
||||
if (question.name.toLowerCase() === rec.name.toLowerCase() && question.type.toUpperCase() === rec.type) {
|
||||
return {
|
||||
name: rec.name,
|
||||
name: question.name,
|
||||
type: rec.type,
|
||||
class: 'IN',
|
||||
ttl: rec.ttl,
|
||||
@@ -313,17 +313,23 @@ export class DnsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all DNS records matching a name and type under a domain.
|
||||
* Used for ACME challenge cleanup (may have multiple TXT records at the same name).
|
||||
* Delete DNS records matching a name and type under a domain.
|
||||
* When value is provided, only that exact record is removed so parallel ACME
|
||||
* challenges for the same host can coexist.
|
||||
*/
|
||||
public async deleteRecordsByNameAndType(
|
||||
domainId: string,
|
||||
name: string,
|
||||
type: TDnsRecordType,
|
||||
value?: string,
|
||||
): Promise<void> {
|
||||
const records = await DnsRecordDoc.findByDomainId(domainId);
|
||||
for (const rec of records) {
|
||||
if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
|
||||
if (
|
||||
rec.name.toLowerCase() === name.toLowerCase()
|
||||
&& rec.type === type
|
||||
&& (value === undefined || rec.value === value)
|
||||
) {
|
||||
await this.deleteRecord(rec.id);
|
||||
}
|
||||
}
|
||||
@@ -358,9 +364,15 @@ export class DnsManager {
|
||||
'Add the domain in Domains before issuing certificates.',
|
||||
);
|
||||
}
|
||||
// Clean leftover challenge records first to avoid duplicates.
|
||||
// Clean only the same challenge value. Exact + wildcard SAN orders can
|
||||
// legitimately need multiple TXT records at the same name.
|
||||
try {
|
||||
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||
await self.deleteRecordsByNameAndType(
|
||||
domainDoc.id,
|
||||
dnsChallenge.hostName,
|
||||
'TXT',
|
||||
dnsChallenge.challenge,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -381,7 +393,12 @@ export class DnsManager {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||
await self.deleteRecordsByNameAndType(
|
||||
domainDoc.id,
|
||||
dnsChallenge.hostName,
|
||||
'TXT',
|
||||
dnsChallenge.challenge,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -72,16 +71,6 @@ export class OpsServer {
|
||||
this.adminHandler = new handlers.AdminHandler(this);
|
||||
await this.adminHandler.initialize();
|
||||
|
||||
// viewRouter middleware: requires valid identity (any logged-in user)
|
||||
this.viewRouter.addMiddleware(async (typedRequest) => {
|
||||
await requireValidIdentity(this.adminHandler, typedRequest.request);
|
||||
});
|
||||
|
||||
// adminRouter middleware: requires admin identity
|
||||
this.adminRouter.addMiddleware(async (typedRequest) => {
|
||||
await requireAdminIdentity(this.adminHandler, typedRequest.request);
|
||||
});
|
||||
|
||||
// Connect auth routers to the main typedrouter
|
||||
this.typedrouter.addTypedRouter(this.viewRouter);
|
||||
this.typedrouter.addTypedRouter(this.adminRouter);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,7 +24,8 @@ export class AdminHandler {
|
||||
// JWT instance
|
||||
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
|
||||
// Ephemeral bootstrap users. Persisted accounts take over once an active admin exists.
|
||||
// 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;
|
||||
@@ -87,9 +88,12 @@ export class AdminHandler {
|
||||
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
||||
*/
|
||||
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
||||
if (await this.hasPersistentAdminAccount()) {
|
||||
const store = this.getAccountStore();
|
||||
const accounts = await store!.listAccounts();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -101,16 +105,14 @@ export class AdminHandler {
|
||||
}
|
||||
|
||||
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
|
||||
const dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
|
||||
const store = this.getAccountStore();
|
||||
const dbReady = !!store;
|
||||
const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false;
|
||||
const accountState = await this.getPersistentAccountState();
|
||||
const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
|
||||
return {
|
||||
dbEnabled,
|
||||
dbReady,
|
||||
hasPersistentAdmin,
|
||||
needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin,
|
||||
ephemeralAdminAvailable: !hasPersistentAdmin,
|
||||
dbEnabled: accountState.dbEnabled,
|
||||
dbReady: accountState.dbReady,
|
||||
hasPersistentAdmin: accountState.hasPersistentAdmin,
|
||||
needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
|
||||
ephemeralAdminAvailable: bootstrapAvailable,
|
||||
idpGlobalConfigured: this.isIdpGlobalConfigured(),
|
||||
};
|
||||
}
|
||||
@@ -258,12 +260,18 @@ export class AdminHandler {
|
||||
this.opsServerRef.adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||
'createInitialAdminUser',
|
||||
async (dataArg) => this.createInitialAdminUser({
|
||||
email: dataArg.email,
|
||||
name: dataArg.name,
|
||||
password: dataArg.password,
|
||||
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
||||
})
|
||||
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,
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -300,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,
|
||||
};
|
||||
@@ -314,52 +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,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await this.resolveUser(jwtData.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: user.id,
|
||||
name: user.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 };
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -372,45 +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;
|
||||
}
|
||||
|
||||
const user = await this.resolveUser(jwtData.userId);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataArg.identity.role && dataArg.identity.role !== user.role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(await this.validateIdentity(dataArg.identity));
|
||||
},
|
||||
{
|
||||
failedHint: 'identity is not valid',
|
||||
@@ -425,14 +353,8 @@ 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',
|
||||
@@ -440,15 +362,62 @@ export class AdminHandler {
|
||||
}
|
||||
);
|
||||
|
||||
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> {
|
||||
if (await this.hasPersistentAdminAccount()) {
|
||||
const store = this.getAccountStore();
|
||||
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: store!,
|
||||
store: accountState.store!,
|
||||
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
|
||||
});
|
||||
const result = await authService.authenticate({
|
||||
@@ -468,8 +437,13 @@ export class AdminHandler {
|
||||
}
|
||||
|
||||
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
|
||||
if (await this.hasPersistentAdminAccount()) {
|
||||
const account = await this.getAccountStore()!.getAccountById(userIdArg);
|
||||
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;
|
||||
}
|
||||
@@ -479,13 +453,25 @@ export class AdminHandler {
|
||||
return this.users.get(userIdArg) || null;
|
||||
}
|
||||
|
||||
private async hasPersistentAdminAccount(): Promise<boolean> {
|
||||
const store = this.getAccountStore();
|
||||
return store ? store.hasActiveAdminAccount() : false;
|
||||
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.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) {
|
||||
if (!this.isPersistenceEnabled()) {
|
||||
return null;
|
||||
}
|
||||
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
export class ConfigHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -17,6 +18,7 @@ export class ConfigHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'config:read' });
|
||||
const config = await this.getConfiguration();
|
||||
return {
|
||||
config,
|
||||
@@ -206,7 +208,7 @@ export class ConfigHandler {
|
||||
hubDomain: riCfg?.hubDomain || null,
|
||||
tlsMode,
|
||||
connectedEdgeIps,
|
||||
performance: riCfg?.performance,
|
||||
performance: dcRouter.remoteIngressManager?.getHubPerformanceConfig() || riCfg?.performance,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
export class RadiusHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -19,6 +20,7 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||
'getRadiusClients',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -43,6 +45,10 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||
'setRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'radius:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -64,6 +70,10 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||
'removeRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'radius:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -88,6 +98,7 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||
'getVlanMappings',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -124,6 +135,10 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||
'setVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'radius:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -156,6 +171,10 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||
'removeVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'radius:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -177,6 +196,10 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||
'updateVlanConfig',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'radius:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -209,6 +232,7 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||
'testVlanAssignment',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -243,6 +267,7 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||
'getRadiusSessions',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -292,6 +317,10 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||
'disconnectRadiusSession',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'radius:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -317,6 +346,7 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||
'getRadiusAccountingSummary',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
@@ -354,6 +384,7 @@ export class RadiusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||
'getRadiusStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
export class RemoteIngressHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -18,6 +19,7 @@ export class RemoteIngressHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
|
||||
'getRemoteIngresses',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
if (!manager) {
|
||||
return { edges: [] };
|
||||
@@ -46,29 +48,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 +76,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 +102,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 +152,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 +178,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 +188,55 @@ 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() || {
|
||||
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 settings = await this.opsServerRef.dcRouterRef.updateRemoteIngressHubSettings(
|
||||
{ performance: dataArg.performance },
|
||||
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' };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +69,7 @@ export class TargetProfileHandler {
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
||||
createdBy: userId,
|
||||
});
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
@@ -111,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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* Handler for OpsServer user accounts. Registers on adminRouter,
|
||||
@@ -20,7 +21,12 @@ export class UsersHandler {
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||
'listUsers',
|
||||
async (_dataArg) => {
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'users:read',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
const users = await this.opsServerRef.adminHandler.listUsers();
|
||||
return { users };
|
||||
},
|
||||
@@ -30,23 +36,37 @@ export class UsersHandler {
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateUser>(
|
||||
'createUser',
|
||||
async (dataArg) => this.opsServerRef.adminHandler.createUser({
|
||||
email: dataArg.email,
|
||||
name: dataArg.name,
|
||||
role: dataArg.role,
|
||||
password: dataArg.password,
|
||||
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
||||
}),
|
||||
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) => this.opsServerRef.adminHandler.deleteUser({
|
||||
id: dataArg.id,
|
||||
requestingUserId: dataArg.identity.userId,
|
||||
}),
|
||||
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,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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,39 +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 */ }
|
||||
}
|
||||
|
||||
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');
|
||||
const auth = await requireOpsAuth(this.opsServerRef, request, {
|
||||
scope: requiredScope,
|
||||
requireAdminIdentity: requiredScope?.endsWith(':write'),
|
||||
});
|
||||
return { userId: auth.userId, isAdmin: auth.isAdmin, token: auth.token };
|
||||
}
|
||||
|
||||
private async requireAdmin(request: { identity?: interfaces.data.IIdentity }): Promise<string> {
|
||||
if (request.identity?.jwt) {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('admin identity required');
|
||||
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 {
|
||||
@@ -83,7 +68,7 @@ export class WorkHosterHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListGatewayClients>(
|
||||
'listGatewayClients',
|
||||
async (dataArg) => {
|
||||
await this.requireAdmin(dataArg);
|
||||
await this.requireAdmin(dataArg, 'gateway-clients:read');
|
||||
return { gatewayClients: await this.listManagedGatewayClients() };
|
||||
},
|
||||
),
|
||||
@@ -154,7 +139,7 @@ export class WorkHosterHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClientToken>(
|
||||
'createGatewayClientToken',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAdmin(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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
+9
-9
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+1
-1
@@ -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...
|
||||
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
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, 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;
|
||||
|
||||
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,8 +45,12 @@ export class RemoteIngressManager {
|
||||
private edges: Map<string, IRemoteIngress> = new Map();
|
||||
private routes: IDcRouterRouteConfig[] = [];
|
||||
private firewallConfig?: IRemoteIngressFirewallConfig;
|
||||
private hubSettings: IRemoteIngressHubSettings = {
|
||||
updatedAt: 0,
|
||||
updatedBy: 'default',
|
||||
};
|
||||
|
||||
constructor() {
|
||||
constructor(private seedHubPerformance?: IRemoteIngressPerformanceConfig) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,12 +72,35 @@ 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) {
|
||||
const seedPerformance = this.normalizePerformanceConfig(this.seedHubPerformance);
|
||||
if (seedPerformance) {
|
||||
doc = new RemoteIngressHubSettingsDoc();
|
||||
doc.settingsId = 'remote-ingress-hub-settings';
|
||||
doc.performance = seedPerformance;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'seed';
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
|
||||
this.hubSettings = doc ? this.toHubSettings(doc) : {
|
||||
updatedAt: 0,
|
||||
updatedBy: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +117,38 @@ 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: { performance?: IRemoteIngressPerformanceConfig },
|
||||
updatedBy: string,
|
||||
): Promise<IRemoteIngressHubSettings> {
|
||||
let doc = await RemoteIngressHubSettingsDoc.load();
|
||||
if (!doc) {
|
||||
doc = new RemoteIngressHubSettingsDoc();
|
||||
doc.settingsId = 'remote-ingress-hub-settings';
|
||||
}
|
||||
|
||||
doc.performance = this.normalizePerformanceConfig(updates.performance);
|
||||
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 +257,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 +270,7 @@ export class RemoteIngressManager {
|
||||
listenPorts,
|
||||
enabled: true,
|
||||
autoDerivePorts,
|
||||
performance,
|
||||
tags: tags || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -237,6 +307,7 @@ export class RemoteIngressManager {
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
enabled?: boolean;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
tags?: string[];
|
||||
},
|
||||
): Promise<IRemoteIngress | null> {
|
||||
@@ -249,6 +320,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 +389,108 @@ 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 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 {
|
||||
performance: doc.performance,
|
||||
updatedAt: doc.updatedAt,
|
||||
updatedBy: doc.updatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -64,30 +66,51 @@ 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 } : {}),
|
||||
} 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 +122,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 +169,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -145,6 +152,8 @@ export class VpnManager {
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
socketForwardProxyProtocolSource: 'remoteIp',
|
||||
socketForwardProxyProtocolVpnMetadata: true,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint,
|
||||
clientAllowedIPs: [subnet],
|
||||
@@ -173,6 +182,9 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshClientSourceIps(false);
|
||||
this.startClientSourceIpPolling();
|
||||
|
||||
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||
}
|
||||
|
||||
@@ -180,6 +192,7 @@ export class VpnManager {
|
||||
* Stop the VPN server.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.stopClientSourceIpPolling();
|
||||
if (this.vpnServer) {
|
||||
try {
|
||||
await this.vpnServer.stopServer();
|
||||
@@ -189,6 +202,11 @@ export class VpnManager {
|
||||
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');
|
||||
}
|
||||
@@ -246,6 +264,7 @@ export class VpnManager {
|
||||
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
|
||||
bundle.wireguardConfig,
|
||||
doc.targetProfileIds || [],
|
||||
doc.clientId,
|
||||
);
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
@@ -287,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();
|
||||
}
|
||||
@@ -328,6 +348,7 @@ export class VpnManager {
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
}
|
||||
this.clientSourceIps.delete(clientId);
|
||||
this.config.onClientChanged?.();
|
||||
}
|
||||
|
||||
@@ -380,6 +401,7 @@ export class VpnManager {
|
||||
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
|
||||
bundle.wireguardConfig,
|
||||
client?.targetProfileIds || [],
|
||||
clientId,
|
||||
);
|
||||
|
||||
// Update persisted entry with new keys (including private key for export/QR)
|
||||
@@ -413,7 +435,11 @@ export class VpnManager {
|
||||
);
|
||||
}
|
||||
|
||||
config = await this.rewriteWireGuardAllowedIPs(config, persisted?.targetProfileIds || []);
|
||||
config = await this.rewriteWireGuardAllowedIPs(
|
||||
config,
|
||||
persisted?.targetProfileIds || [],
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -445,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.
|
||||
*/
|
||||
@@ -533,10 +660,15 @@ export class VpnManager {
|
||||
private async rewriteWireGuardAllowedIPs(
|
||||
wireguardConfig: string,
|
||||
targetProfileIds: string[],
|
||||
clientId?: string,
|
||||
): Promise<string> {
|
||||
if (!this.config.getClientAllowedIPs) return wireguardConfig;
|
||||
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(targetProfileIds);
|
||||
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(', ')}`;
|
||||
|
||||
@@ -587,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
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface IRemoteIngress {
|
||||
enabled: boolean;
|
||||
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
|
||||
autoDerivePorts: boolean;
|
||||
/** Optional per-edge performance overrides. */
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
tags?: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
@@ -55,6 +57,16 @@ export interface IRemoteIngressPerformanceConfig {
|
||||
maxStreamWindowBytes?: number;
|
||||
sustainedStreamWindowBytes?: number;
|
||||
quicDatagramReceiveBufferBytes?: number;
|
||||
streamFramePayloadBytes?: number;
|
||||
firstDataConnectTimeoutMs?: number;
|
||||
clientWriteTimeoutMs?: number;
|
||||
serverFirstPorts?: number[];
|
||||
}
|
||||
|
||||
export interface IRemoteIngressHubSettings {
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressPerformanceEffective {
|
||||
@@ -65,6 +77,10 @@ export interface IRemoteIngressPerformanceEffective {
|
||||
maxStreamWindowBytes: number;
|
||||
sustainedStreamWindowBytes: number;
|
||||
quicDatagramReceiveBufferBytes: number;
|
||||
streamFramePayloadBytes: number;
|
||||
firstDataConnectTimeoutMs: number;
|
||||
clientWriteTimeoutMs: number;
|
||||
serverFirstPorts: number[];
|
||||
}
|
||||
|
||||
export interface IRemoteIngressFlowControlStatus {
|
||||
|
||||
@@ -8,22 +8,52 @@ export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||
// Route Management Data Types
|
||||
// ============================================================================
|
||||
|
||||
export type TApiTokenScope =
|
||||
| '*'
|
||||
| 'routes:read' | 'routes:write'
|
||||
| 'config:read'
|
||||
| 'certificates:read' | 'certificates:write'
|
||||
| 'tokens:read' | 'tokens:manage'
|
||||
| 'source-profiles:read' | 'source-profiles:write'
|
||||
| 'target-profiles:read' | 'target-profiles:write'
|
||||
| 'targets:read' | 'targets:write'
|
||||
| 'dns-providers:read' | 'dns-providers:write'
|
||||
| 'domains:read' | 'domains:write'
|
||||
| 'dns-records:read' | 'dns-records:write'
|
||||
| 'acme-config:read' | 'acme-config:write'
|
||||
| 'email-domains:read' | 'email-domains:write'
|
||||
| 'gateway-clients:read' | 'gateway-clients:write'
|
||||
| 'workhosters:read' | 'workhosters:write';
|
||||
export const apiTokenScopes = [
|
||||
'*',
|
||||
'routes:read',
|
||||
'routes:write',
|
||||
'config:read',
|
||||
'stats:read',
|
||||
'logs:read',
|
||||
'security:read',
|
||||
'security:write',
|
||||
'emails:read',
|
||||
'emails:write',
|
||||
'certificates:read',
|
||||
'certificates:write',
|
||||
'tokens:read',
|
||||
'tokens:manage',
|
||||
'users:read',
|
||||
'users:manage',
|
||||
'source-profiles:read',
|
||||
'source-profiles:write',
|
||||
'target-profiles:read',
|
||||
'target-profiles:write',
|
||||
'targets:read',
|
||||
'targets:write',
|
||||
'dns-providers:read',
|
||||
'dns-providers:write',
|
||||
'domains:read',
|
||||
'domains:write',
|
||||
'dns-records:read',
|
||||
'dns-records:write',
|
||||
'acme-config:read',
|
||||
'acme-config:write',
|
||||
'email-domains:read',
|
||||
'email-domains:write',
|
||||
'remote-ingress:read',
|
||||
'remote-ingress:write',
|
||||
'vpn:read',
|
||||
'vpn:write',
|
||||
'radius:read',
|
||||
'radius:write',
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
] as const;
|
||||
|
||||
export type TApiTokenScope = typeof apiTokenScopes[number];
|
||||
|
||||
export type TGatewayClientType = 'onebox' | 'cloudly' | 'custom';
|
||||
/** @deprecated Use TGatewayClientType. */
|
||||
@@ -74,6 +104,112 @@ export interface ISourceProfile {
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface IRouteSourcePolicyExceededAction {
|
||||
type: '429';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const routePathClasses = [
|
||||
'git-smart-http',
|
||||
'static',
|
||||
'normal-html',
|
||||
'expensive-html',
|
||||
'raw',
|
||||
'archive',
|
||||
] as const;
|
||||
|
||||
export type TRoutePathClass = typeof routePathClasses[number];
|
||||
|
||||
export const giteaRoutePathClassLabels: Record<TRoutePathClass, string> = {
|
||||
'git-smart-http': 'Git Smart HTTP',
|
||||
static: 'Static Assets',
|
||||
'normal-html': 'Normal HTML',
|
||||
'expensive-html': 'Expensive HTML',
|
||||
raw: 'Raw Files',
|
||||
archive: 'Archives',
|
||||
};
|
||||
|
||||
export const giteaRoutePathClassPatterns: Record<TRoutePathClass, string[]> = {
|
||||
'git-smart-http': [
|
||||
'/*/*.git/info/refs',
|
||||
'/*/*.git/git-upload-pack',
|
||||
'/*/*.git/git-receive-pack',
|
||||
'/*/*.git/info/lfs',
|
||||
'/*/*.git/info/lfs/*',
|
||||
],
|
||||
static: [
|
||||
'/assets/*',
|
||||
'/avatars/*',
|
||||
'/repo-avatars/*',
|
||||
'/user/avatar/*',
|
||||
'/img/*',
|
||||
'/css/*',
|
||||
'/js/*',
|
||||
'/fonts/*',
|
||||
'/favicon.ico',
|
||||
],
|
||||
'normal-html': [],
|
||||
'expensive-html': [
|
||||
'/explore/*',
|
||||
'/issues',
|
||||
'/issues/*',
|
||||
'/pulls',
|
||||
'/pulls/*',
|
||||
'/search',
|
||||
'/*/*/commits/*',
|
||||
'/*/*/graph',
|
||||
'/*/*/activity',
|
||||
'/*/*/stars',
|
||||
'/*/*/forks',
|
||||
'/*/*/watchers',
|
||||
'/*/*/issues',
|
||||
'/*/*/pulls',
|
||||
'/*/*/projects',
|
||||
'/*/*/actions',
|
||||
'/*/*/packages',
|
||||
],
|
||||
raw: [
|
||||
'/*/*/raw/*',
|
||||
'/*/*/src/*',
|
||||
],
|
||||
archive: [
|
||||
'/*/*/archive/*',
|
||||
'/*/*/releases/download/*',
|
||||
],
|
||||
};
|
||||
|
||||
export interface IRoutePathPolicyBinding {
|
||||
id?: string;
|
||||
pathClass: TRoutePathClass;
|
||||
/** Optional custom patterns. When omitted, the Gitea defaults for the class are used. */
|
||||
pathPatterns?: string[];
|
||||
/** Optional path-class override for the source binding's rate limit. */
|
||||
rateLimit?: IRouteSecurity['rateLimit'];
|
||||
/** Optional path-class override for the source binding's connection limit. */
|
||||
maxConnections?: IRouteSecurity['maxConnections'];
|
||||
onExceeded?: IRouteSourcePolicyExceededAction;
|
||||
}
|
||||
|
||||
export interface IRouteSourcePolicyBinding {
|
||||
id?: string;
|
||||
sourceProfileRef: string;
|
||||
/** Snapshot of the profile name at resolution time, for display. */
|
||||
sourceProfileName?: string;
|
||||
/** Optional route-level override for the referenced profile's rate limit. */
|
||||
rateLimit?: IRouteSecurity['rateLimit'];
|
||||
/** Optional route-level override for the referenced profile's connection limit. */
|
||||
maxConnections?: IRouteSecurity['maxConnections'];
|
||||
/** Initial source-policy slice only supports explicit 429 behavior. */
|
||||
onExceeded?: IRouteSourcePolicyExceededAction;
|
||||
/** Optional path-class variants inside this source binding. Path-specific variants win over fallback variants. */
|
||||
pathPolicies?: IRoutePathPolicyBinding[];
|
||||
}
|
||||
|
||||
export interface IRouteSourcePolicy {
|
||||
/** Ordered source profile bindings. The first matching binding wins. */
|
||||
bindings: IRouteSourcePolicyBinding[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Network Target Types
|
||||
// ============================================================================
|
||||
@@ -102,6 +238,8 @@ export interface INetworkTarget {
|
||||
export interface IRouteMetadata {
|
||||
/** ID of the SourceProfileDoc used to resolve this route's security. */
|
||||
sourceProfileRef?: string;
|
||||
/** Ordered source policy. When present, it supersedes sourceProfileRef. */
|
||||
sourcePolicy?: IRouteSourcePolicy;
|
||||
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
|
||||
networkTargetRef?: string;
|
||||
/** Snapshot of the profile name at resolution time, for display. */
|
||||
|
||||
@@ -159,6 +159,17 @@ export interface IDomainActivity {
|
||||
requestsLastMinute?: number;
|
||||
}
|
||||
|
||||
export interface IAsnActivity {
|
||||
asn: number;
|
||||
organization: string;
|
||||
country: string | null;
|
||||
activeConnections: number;
|
||||
ipCount: number;
|
||||
bytesInPerSecond: number;
|
||||
bytesOutPerSecond: number;
|
||||
sampleIps: string[];
|
||||
}
|
||||
|
||||
export interface INetworkMetrics {
|
||||
totalBandwidth: {
|
||||
in: number;
|
||||
@@ -186,6 +197,7 @@ export interface INetworkMetrics {
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
topASNs: IAsnActivity[];
|
||||
domainActivity: IDomainActivity[];
|
||||
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
|
||||
requestsPerSecond?: number;
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface ITargetProfile {
|
||||
targets?: ITargetProfileTarget[];
|
||||
/** Route references by stored route ID. Legacy route names are normalized when unique. */
|
||||
routeRefs?: string[];
|
||||
/** Also allow routes whose source security would allow the VPN client's real connecting IP. */
|
||||
allowRoutesByClientSourceIp?: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface IVpnConnectedClient {
|
||||
bytesSent: number;
|
||||
bytesReceived: number;
|
||||
transport: string;
|
||||
/** Real client IP:port reported by the VPN transport, when available. */
|
||||
remoteAddr?: string;
|
||||
/** Parsed real client IP reported by the VPN transport, when available. */
|
||||
sourceIp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-3
@@ -22,7 +22,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `data` | Shared runtime-shaped models such as identities, routes, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
|
||||
| `data` | Shared runtime-shaped models such as identities, routes, route source policies, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
|
||||
| `requests` | TypedRequest request/response contracts for OpsServer methods. |
|
||||
| `typedrequestInterfaces` | Helper types re-exported from `@api.global/typedrequest-interfaces` through `plugins.ts`. |
|
||||
|
||||
@@ -31,7 +31,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
| Area | Examples |
|
||||
| --- | --- |
|
||||
| Auth | admin login, first-admin bootstrap status/creation, logout, identity verification, users |
|
||||
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata |
|
||||
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata, ordered source/path policies |
|
||||
| Access | API tokens, source profiles, target profiles, network targets |
|
||||
| DNS and domains | DNS providers, domains, DNS records, ACME config |
|
||||
| Email | email-domain management and email operations |
|
||||
@@ -39,6 +39,18 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
| Observability | stats, combined stats, logs, configuration |
|
||||
| WorkHoster | external app/workhoster route ownership contracts |
|
||||
|
||||
## Route Source Policy Contracts
|
||||
|
||||
`data/route-management.ts` exports the source-policy contracts used by the dashboard, API client, and route runtime compiler:
|
||||
|
||||
- `IRouteSourcePolicy` stores ordered route-level source bindings.
|
||||
- `IRouteSourcePolicyBinding` points to a source profile, can override rate limits or connection limits, and can contain path policies.
|
||||
- `IRoutePathPolicyBinding` applies path-class-specific overrides within a source binding.
|
||||
- `IRouteSourcePolicyExceededAction` describes terminal exceeded-limit behavior, currently explicit `429` handling.
|
||||
- `TRoutePathClass` is the string-union type derived from `routePathClasses`.
|
||||
- `routePathClasses` lists the supported classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`.
|
||||
- `giteaRoutePathClassLabels` and `giteaRoutePathClassPatterns` provide the built-in Gitea labels and path patterns, including Git Smart HTTP and Git LFS patterns.
|
||||
|
||||
## Raw TypedRequest Example
|
||||
|
||||
```typescript
|
||||
@@ -86,7 +98,7 @@ Useful source entry points:
|
||||
- `index.ts` exports `data` and `requests` namespaces.
|
||||
- `data/index.ts` groups shared data models.
|
||||
- `requests/index.ts` groups TypedRequest contracts.
|
||||
- `data/route-management.ts` defines route ownership, API token scopes, profiles, and network target shapes.
|
||||
- `data/route-management.ts` defines route ownership, source/path policies, API token scopes, profiles, and network target shapes.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.impl
|
||||
> {
|
||||
method: 'createApiToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
policy?: IApiTokenPolicy;
|
||||
@@ -39,7 +40,8 @@ export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.imple
|
||||
> {
|
||||
method: 'listApiTokens';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
tokens: IApiTokenInfo[];
|
||||
@@ -55,7 +57,8 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl
|
||||
> {
|
||||
method: 'revokeApiToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
@@ -74,7 +77,8 @@ export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implem
|
||||
> {
|
||||
method: 'rollApiToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
@@ -93,7 +97,8 @@ export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.impl
|
||||
> {
|
||||
method: 'toggleApiToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@ import type * as data from '../data/index.js';
|
||||
export interface IReq_GetCombinedMetrics {
|
||||
method: 'getCombinedMetrics';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
identity?: data.IIdentity;
|
||||
apiToken?: string;
|
||||
sections?: {
|
||||
server?: boolean;
|
||||
email?: boolean;
|
||||
@@ -26,4 +27,4 @@ export interface IReq_GetCombinedMetrics {
|
||||
};
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'getConfiguration';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
section?: string;
|
||||
};
|
||||
response: {
|
||||
|
||||
@@ -68,7 +68,8 @@ export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implem
|
||||
> {
|
||||
method: 'getAllEmails';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
emails: IEmail[];
|
||||
@@ -84,7 +85,8 @@ export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.impl
|
||||
> {
|
||||
method: 'getEmailDetail';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
emailId: string;
|
||||
};
|
||||
response: {
|
||||
@@ -101,7 +103,8 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
|
||||
> {
|
||||
method: 'resendEmail';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
emailId: string;
|
||||
};
|
||||
response: {
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface IReq_GetRecentLogs extends plugins.typedrequestInterfaces.imple
|
||||
> {
|
||||
method: 'getRecentLogs';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
limit?: number;
|
||||
@@ -31,7 +32,8 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
|
||||
> {
|
||||
method: 'getLogStream';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
follow?: boolean;
|
||||
filters?: {
|
||||
level?: string[];
|
||||
@@ -53,4 +55,4 @@ export interface IReq_PushLogEntry extends plugins.typedrequestInterfaces.implem
|
||||
entry: statsInterfaces.ILogEntry;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ export interface IReq_GetRadiusClients extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'getRadiusClients';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
clients: Array<{
|
||||
@@ -35,7 +36,8 @@ export interface IReq_SetRadiusClient extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'setRadiusClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
client: {
|
||||
name: string;
|
||||
ipRange: string;
|
||||
@@ -59,7 +61,8 @@ export interface IReq_RemoveRadiusClient extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'removeRadiusClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
};
|
||||
response: {
|
||||
@@ -81,7 +84,8 @@ export interface IReq_GetVlanMappings extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'getVlanMappings';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
mappings: Array<{
|
||||
@@ -108,7 +112,8 @@ export interface IReq_SetVlanMapping extends plugins.typedrequestInterfaces.impl
|
||||
> {
|
||||
method: 'setVlanMapping';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
mapping: {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
@@ -139,7 +144,8 @@ export interface IReq_RemoveVlanMapping extends plugins.typedrequestInterfaces.i
|
||||
> {
|
||||
method: 'removeVlanMapping';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
mac: string;
|
||||
};
|
||||
response: {
|
||||
@@ -157,7 +163,8 @@ export interface IReq_UpdateVlanConfig extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'updateVlanConfig';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
defaultVlan?: number;
|
||||
allowUnknownMacs?: boolean;
|
||||
};
|
||||
@@ -179,7 +186,8 @@ export interface IReq_TestVlanAssignment extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'testVlanAssignment';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
mac: string;
|
||||
};
|
||||
response: {
|
||||
@@ -207,7 +215,8 @@ export interface IReq_GetRadiusSessions extends plugins.typedrequestInterfaces.i
|
||||
> {
|
||||
method: 'getRadiusSessions';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
filter?: {
|
||||
username?: string;
|
||||
nasIpAddress?: string;
|
||||
@@ -243,7 +252,8 @@ export interface IReq_DisconnectRadiusSession extends plugins.typedrequestInterf
|
||||
> {
|
||||
method: 'disconnectRadiusSession';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
sessionId: string;
|
||||
reason?: string;
|
||||
};
|
||||
@@ -262,7 +272,8 @@ export interface IReq_GetRadiusAccountingSummary extends plugins.typedrequestInt
|
||||
> {
|
||||
method: 'getRadiusAccountingSummary';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
@@ -296,7 +307,8 @@ export interface IReq_GetRadiusStatistics extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'getRadiusStatistics';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
stats: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
import type { IRemoteIngress, IRemoteIngressStatus } from '../data/remoteingress.js';
|
||||
import type { IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../data/remoteingress.js';
|
||||
|
||||
// ============================================================================
|
||||
// Remote Ingress Edge Management
|
||||
@@ -15,10 +15,12 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'createRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
tags?: string[];
|
||||
};
|
||||
response: {
|
||||
@@ -36,7 +38,8 @@ export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'deleteRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
@@ -54,12 +57,14 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'updateRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
enabled?: boolean;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
tags?: string[];
|
||||
};
|
||||
response: {
|
||||
@@ -77,7 +82,8 @@ export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequest
|
||||
> {
|
||||
method: 'regenerateRemoteIngressSecret';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
@@ -95,7 +101,8 @@ export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'getRemoteIngresses';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
edges: IRemoteIngress[];
|
||||
@@ -111,7 +118,8 @@ export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfa
|
||||
> {
|
||||
method: 'getRemoteIngressStatus';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
statuses: IRemoteIngressStatus[];
|
||||
@@ -128,7 +136,8 @@ export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedreque
|
||||
> {
|
||||
method: 'getRemoteIngressConnectionToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
edgeId: string;
|
||||
hubHost?: string;
|
||||
};
|
||||
@@ -138,3 +147,40 @@ export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedreque
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hub-level RemoteIngress settings.
|
||||
*/
|
||||
export interface IReq_GetRemoteIngressHubSettings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRemoteIngressHubSettings
|
||||
> {
|
||||
method: 'getRemoteIngressHubSettings';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
settings: IRemoteIngressHubSettings;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hub-level RemoteIngress settings.
|
||||
*/
|
||||
export interface IReq_UpdateRemoteIngressHubSettings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateRemoteIngressHubSettings
|
||||
> {
|
||||
method: 'updateRemoteIngressHubSettings';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
settings?: IRemoteIngressHubSettings;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface IReq_ListSecurityBlockRules extends plugins.typedrequestInterfa
|
||||
> {
|
||||
method: 'listSecurityBlockRules';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
rules: ISecurityBlockRule[];
|
||||
@@ -28,7 +29,8 @@ export interface IReq_CreateSecurityBlockRule extends plugins.typedrequestInterf
|
||||
> {
|
||||
method: 'createSecurityBlockRule';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
type: TSecurityBlockRuleType;
|
||||
value: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
@@ -48,7 +50,8 @@ export interface IReq_UpdateSecurityBlockRule extends plugins.typedrequestInterf
|
||||
> {
|
||||
method: 'updateSecurityBlockRule';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
value?: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
@@ -68,7 +71,8 @@ export interface IReq_DeleteSecurityBlockRule extends plugins.typedrequestInterf
|
||||
> {
|
||||
method: 'deleteSecurityBlockRule';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
@@ -83,7 +87,10 @@ export interface IReq_ListIpIntelligence extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'listIpIntelligence';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
ipAddresses?: string[];
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
records: IIpIntelligenceRecord[];
|
||||
@@ -96,7 +103,8 @@ export interface IReq_GetCompiledSecurityPolicy extends plugins.typedrequestInte
|
||||
> {
|
||||
method: 'getCompiledSecurityPolicy';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
policy: ISecurityCompiledPolicy;
|
||||
@@ -109,7 +117,8 @@ export interface IReq_ListSecurityPolicyAudit extends plugins.typedrequestInterf
|
||||
> {
|
||||
method: 'listSecurityPolicyAudit';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
@@ -123,7 +132,8 @@ export interface IReq_RefreshIpIntelligence extends plugins.typedrequestInterfac
|
||||
> {
|
||||
method: 'refreshIpIntelligence';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
ipAddress: string;
|
||||
};
|
||||
response: {
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface IReq_GetServerStatistics extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'getServerStatistics';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
includeHistory?: boolean;
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
};
|
||||
@@ -29,7 +30,8 @@ export interface IReq_GetEmailStatistics extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'getEmailStatistics';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
domain?: string;
|
||||
includeDetails?: boolean;
|
||||
@@ -49,7 +51,8 @@ export interface IReq_GetDnsStatistics extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'getDnsStatistics';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
domain?: string;
|
||||
includeQueryTypes?: boolean;
|
||||
@@ -69,7 +72,8 @@ export interface IReq_GetRateLimitStatus extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'getRateLimitStatus';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
includeBlocked?: boolean;
|
||||
@@ -91,7 +95,8 @@ export interface IReq_GetSecurityMetrics extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'getSecurityMetrics';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
includeDetails?: boolean;
|
||||
};
|
||||
@@ -112,7 +117,8 @@ export interface IReq_GetActiveConnections extends plugins.typedrequestInterface
|
||||
> {
|
||||
method: 'getActiveConnections';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
|
||||
state?: string;
|
||||
};
|
||||
@@ -137,7 +143,8 @@ export interface IReq_GetQueueStatus extends plugins.typedrequestInterfaces.impl
|
||||
> {
|
||||
method: 'getQueueStatus';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
queueName?: string;
|
||||
};
|
||||
response: {
|
||||
@@ -153,7 +160,8 @@ export interface IReq_GetHealthStatus extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'getHealthStatus';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
detailed?: boolean;
|
||||
};
|
||||
response: {
|
||||
@@ -168,7 +176,8 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'getNetworkStats';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
connectionsByIP: Array<{ ip: string; count: number }>;
|
||||
@@ -181,8 +190,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
|
||||
requestsTotal: number;
|
||||
backends?: statsInterfaces.IBackendInfo[];
|
||||
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||
topASNs: statsInterfaces.IAsnActivity[];
|
||||
domainActivity: statsInterfaces.IDomainActivity[];
|
||||
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces
|
||||
domains?: string[];
|
||||
targets?: ITargetProfileTarget[];
|
||||
routeRefs?: string[];
|
||||
allowRoutesByClientSourceIp?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
@@ -82,6 +83,7 @@ export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces
|
||||
domains?: string[];
|
||||
targets?: ITargetProfileTarget[];
|
||||
routeRefs?: string[];
|
||||
allowRoutesByClientSourceIp?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
|
||||
@@ -14,7 +14,8 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
|
||||
> {
|
||||
method: 'listUsers';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
users: IAdminUserProjection[];
|
||||
@@ -30,7 +31,8 @@ export interface IReq_CreateUser extends plugins.typedrequestInterfaces.implemen
|
||||
> {
|
||||
method: 'createUser';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role: TUserManagementRole;
|
||||
@@ -53,7 +55,8 @@ export interface IReq_DeleteUser extends plugins.typedrequestInterfaces.implemen
|
||||
> {
|
||||
method: 'deleteUser';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface IReq_GetVpnClients extends plugins.typedrequestInterfaces.imple
|
||||
> {
|
||||
method: 'getVpnClients';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
clients: IVpnClient[];
|
||||
@@ -31,7 +32,8 @@ export interface IReq_GetVpnStatus extends plugins.typedrequestInterfaces.implem
|
||||
> {
|
||||
method: 'getVpnStatus';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
status: IVpnServerStatus;
|
||||
@@ -47,7 +49,8 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'createVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
clientId: string;
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
@@ -78,7 +81,8 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'updateVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
clientId: string;
|
||||
description?: string;
|
||||
targetProfileIds?: string[];
|
||||
@@ -106,7 +110,8 @@ export interface IReq_GetVpnConnectedClients extends plugins.typedrequestInterfa
|
||||
> {
|
||||
method: 'getVpnConnectedClients';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
connectedClients: IVpnConnectedClient[];
|
||||
@@ -122,7 +127,8 @@ export interface IReq_DeleteVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'deleteVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
@@ -140,7 +146,8 @@ export interface IReq_EnableVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'enableVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
@@ -158,7 +165,8 @@ export interface IReq_DisableVpnClient extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'disableVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
@@ -176,7 +184,8 @@ export interface IReq_RotateVpnClientKey extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'rotateVpnClientKey';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
@@ -196,7 +205,8 @@ export interface IReq_ExportVpnClientConfig extends plugins.typedrequestInterfac
|
||||
> {
|
||||
method: 'exportVpnClientConfig';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
clientId: string;
|
||||
format: 'smartvpn' | 'wireguard';
|
||||
};
|
||||
@@ -216,7 +226,8 @@ export interface IReq_GetVpnClientTelemetry extends plugins.typedrequestInterfac
|
||||
> {
|
||||
method: 'getVpnClientTelemetry';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
|
||||
@@ -53,7 +53,8 @@ export interface IReq_ListGatewayClients extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'listGatewayClients';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
gatewayClients: IGatewayClient[];
|
||||
@@ -66,7 +67,8 @@ export interface IReq_CreateGatewayClient extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'createGatewayClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id?: string;
|
||||
type: IGatewayClient['type'];
|
||||
name: string;
|
||||
@@ -88,7 +90,8 @@ export interface IReq_UpdateGatewayClient extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'updateGatewayClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -110,7 +113,8 @@ export interface IReq_DeleteGatewayClient extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'deleteGatewayClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
@@ -125,7 +129,8 @@ export interface IReq_CreateGatewayClientToken extends plugins.typedrequestInter
|
||||
> {
|
||||
method: 'createGatewayClientToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
gatewayClientId: string;
|
||||
name?: string;
|
||||
expiresInDays?: number | null;
|
||||
|
||||
@@ -19,6 +19,172 @@ export interface IMigrationRunner {
|
||||
run(): Promise<IMigrationRunResult>;
|
||||
}
|
||||
|
||||
type TMigrationSecurity = Record<string, any>;
|
||||
|
||||
const DEFAULT_SOURCE_PROFILES: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
security: TMigrationSecurity;
|
||||
}> = [
|
||||
{
|
||||
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: 'Public fallback source profile with per-IP request limiting',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 120,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function mergeMigrationSecurityFields(
|
||||
base: TMigrationSecurity | undefined,
|
||||
override: TMigrationSecurity | undefined,
|
||||
): TMigrationSecurity {
|
||||
if (!base && !override) return {};
|
||||
if (!base) return structuredClone(override || {});
|
||||
if (!override) return structuredClone(base || {});
|
||||
|
||||
const merged: TMigrationSecurity = structuredClone(base);
|
||||
|
||||
if (override.ipAllowList || base.ipAllowList) {
|
||||
merged.ipAllowList = [
|
||||
...new Set([
|
||||
...(base.ipAllowList || []),
|
||||
...(override.ipAllowList || []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if (override.ipBlockList || base.ipBlockList) {
|
||||
merged.ipBlockList = [
|
||||
...new Set([
|
||||
...(base.ipBlockList || []),
|
||||
...(override.ipBlockList || []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
for (const key of ['maxConnections', 'rateLimit', 'authentication', 'basicAuth', 'jwtAuth', 'vpn']) {
|
||||
if (override[key] !== undefined) {
|
||||
merged[key] = structuredClone(override[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveMigrationSourceProfileSecurity(
|
||||
profileId: string,
|
||||
profiles: Map<string, any>,
|
||||
visited = new Set<string>(),
|
||||
depth = 0,
|
||||
): TMigrationSecurity | null {
|
||||
if (depth > 5 || visited.has(profileId)) return null;
|
||||
|
||||
const profile = profiles.get(profileId);
|
||||
if (!profile) return null;
|
||||
|
||||
visited.add(profileId);
|
||||
let baseSecurity: TMigrationSecurity = {};
|
||||
const extendsProfiles = Array.isArray(profile.extendsProfiles) ? profile.extendsProfiles : [];
|
||||
for (const parentId of extendsProfiles) {
|
||||
if (typeof parentId !== 'string') continue;
|
||||
const parentSecurity = resolveMigrationSourceProfileSecurity(
|
||||
parentId,
|
||||
profiles,
|
||||
new Set(visited),
|
||||
depth + 1,
|
||||
);
|
||||
if (parentSecurity) {
|
||||
baseSecurity = mergeMigrationSecurityFields(baseSecurity, parentSecurity);
|
||||
}
|
||||
}
|
||||
|
||||
return mergeMigrationSecurityFields(baseSecurity, profile.security || {});
|
||||
}
|
||||
|
||||
async function rematerializeSourceProfileRouteSecurity(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const profileCollection = ctx.mongo!.collection('SourceProfileDoc');
|
||||
const routeCollection = ctx.mongo!.collection('RouteDoc');
|
||||
const profiles = new Map<string, any>();
|
||||
|
||||
for await (const profile of profileCollection.find({})) {
|
||||
if (typeof (profile as any).id === 'string') {
|
||||
profiles.set((profile as any).id, profile);
|
||||
}
|
||||
}
|
||||
|
||||
let inspected = 0;
|
||||
let migrated = 0;
|
||||
let skippedMissingProfile = 0;
|
||||
const now = Date.now();
|
||||
|
||||
for await (const routeDoc of routeCollection.find({})) {
|
||||
const sourceProfileRef = (routeDoc as any).metadata?.sourceProfileRef;
|
||||
if (typeof sourceProfileRef !== 'string' || sourceProfileRef.trim() === '') continue;
|
||||
inspected++;
|
||||
|
||||
const resolvedSecurity = resolveMigrationSourceProfileSecurity(sourceProfileRef, profiles);
|
||||
const profile = profiles.get(sourceProfileRef);
|
||||
if (!resolvedSecurity || !profile) {
|
||||
skippedMissingProfile++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentSecurity = (routeDoc as any).route?.security || {};
|
||||
const securityChanged = JSON.stringify(currentSecurity) !== JSON.stringify(resolvedSecurity);
|
||||
const profileNameChanged = (routeDoc as any).metadata?.sourceProfileName !== profile.name;
|
||||
if (!securityChanged && !profileNameChanged) continue;
|
||||
|
||||
const query = (routeDoc as any)._id
|
||||
? { _id: (routeDoc as any)._id }
|
||||
: { id: (routeDoc as any).id };
|
||||
await routeCollection.updateOne(query, {
|
||||
$set: {
|
||||
'route.security': structuredClone(resolvedSecurity),
|
||||
'metadata.sourceProfileName': profile.name,
|
||||
'metadata.lastResolvedAt': now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`rematerialize-source-profile-route-security: migrated ${migrated}/${inspected} route(s), skipped ${skippedMissingProfile} missing profile ref(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
async function migrateTargetProfileTargetHosts(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
@@ -70,6 +236,40 @@ async function backfillSystemRouteKeys(ctx: {
|
||||
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
|
||||
}
|
||||
|
||||
async function seedMissingDefaultSourceProfiles(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('SourceProfileDoc');
|
||||
const now = Date.now();
|
||||
let inserted = 0;
|
||||
let existing = 0;
|
||||
|
||||
for (const profile of DEFAULT_SOURCE_PROFILES) {
|
||||
const existingProfile = await collection.findOne({ name: profile.name });
|
||||
if (existingProfile) {
|
||||
existing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await collection.insertOne({
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
name: profile.name,
|
||||
description: profile.description,
|
||||
security: structuredClone(profile.security),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'system',
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`seed-missing-default-source-profiles: inserted ${inserted}, already present ${existing}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -89,6 +289,8 @@ export async function createMigrationRunner(
|
||||
db: db as any,
|
||||
// Brand-new installs skip all migrations and stamp directly to the current version.
|
||||
freshInstallVersion: targetVersion,
|
||||
// dcrouter uses the package version as targetVersion; bridge releases without DB changes.
|
||||
targetVersionStrategy: 'bridge',
|
||||
});
|
||||
|
||||
// Register steps in execution order. Each step's .from() must match the
|
||||
@@ -165,6 +367,18 @@ export async function createMigrationRunner(
|
||||
.description('Backfill RouteDoc.systemKey for persisted config/email/dns routes')
|
||||
.up(async (ctx) => {
|
||||
await backfillSystemRouteKeys(ctx);
|
||||
})
|
||||
.step('rematerialize-source-profile-route-security')
|
||||
.from('13.18.0').to('13.40.2')
|
||||
.description('Replace stale route security with resolved source profile security')
|
||||
.up(async (ctx) => {
|
||||
await rematerializeSourceProfileRouteSecurity(ctx);
|
||||
})
|
||||
.step('seed-missing-default-source-profiles')
|
||||
.from('13.40.2').to('13.42.0')
|
||||
.description('Seed missing default source profiles for source-policy presets')
|
||||
.up(async (ctx) => {
|
||||
await seedMissingDefaultSourceProfiles(ctx);
|
||||
});
|
||||
|
||||
return migration;
|
||||
|
||||
@@ -25,7 +25,7 @@ If you boot `DcRouter`, you usually do not install or call this package directly
|
||||
```typescript
|
||||
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
|
||||
|
||||
const migration = await createMigrationRunner(db, '13.25.0');
|
||||
const migration = await createMigrationRunner(db, '<current-version>');
|
||||
const result = await migration.run();
|
||||
|
||||
console.log(result.currentVersionBefore, result.currentVersionAfter);
|
||||
@@ -41,6 +41,8 @@ The current migration chain covers:
|
||||
- route collection unification from `StoredRouteDoc` to `RouteDoc`
|
||||
- route `origin` backfill for migrated API routes
|
||||
- `systemKey` backfill for persisted config, email, and DNS routes
|
||||
- source-profile route-security rematerialization for routes with legacy `metadata.sourceProfileRef`
|
||||
- `seed-missing-default-source-profiles` from `13.40.2` to `13.42.0`, which inserts missing `TRUSTED NETWORKS`, `AI CRAWLERS`, and `PUBLIC` source profiles by name without mutating existing profiles
|
||||
|
||||
## Migration Rules
|
||||
|
||||
|
||||
@@ -74,6 +74,10 @@ export function getOciContainerConfig(): IDcRouterOptions {
|
||||
options.dnsScopes = dnsScopes;
|
||||
}
|
||||
|
||||
if (process.env.DCROUTER_DNS_BIND_INTERFACE) {
|
||||
options.dnsBindInterface = process.env.DCROUTER_DNS_BIND_INTERFACE;
|
||||
}
|
||||
|
||||
// Email config
|
||||
const emailHostname = process.env.DCROUTER_EMAIL_HOSTNAME;
|
||||
const emailPorts = parseCommaSeparatedNumbers(process.env.DCROUTER_EMAIL_PORTS);
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.31.0',
|
||||
version: '13.42.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+135
-49
@@ -55,6 +55,7 @@ export interface INetworkState {
|
||||
totalBytes: { in: number; out: number };
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||
topASNs: interfaces.data.IAsnActivity[];
|
||||
throughputByIP: Array<{ ip: string; in: number; out: number }>;
|
||||
ipIntelligence: interfaces.data.IIpIntelligenceRecord[];
|
||||
domainActivity: interfaces.data.IDomainActivity[];
|
||||
@@ -176,6 +177,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
||||
totalBytes: { in: 0, out: 0 },
|
||||
topIPs: [],
|
||||
topIPsByBandwidth: [],
|
||||
topASNs: [],
|
||||
throughputByIP: [],
|
||||
ipIntelligence: [],
|
||||
domainActivity: [],
|
||||
@@ -258,6 +260,7 @@ export const acmeConfigStatePart = await appState.getStatePart<IAcmeConfigState>
|
||||
export interface IRemoteIngressState {
|
||||
edges: interfaces.data.IRemoteIngress[];
|
||||
statuses: interfaces.data.IRemoteIngressStatus[];
|
||||
hubSettings: interfaces.data.IRemoteIngressHubSettings | null;
|
||||
selectedEdgeId: string | null;
|
||||
newEdgeId: string | null;
|
||||
isLoading: boolean;
|
||||
@@ -270,6 +273,7 @@ export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngress
|
||||
{
|
||||
edges: [],
|
||||
statuses: [],
|
||||
hubSettings: null,
|
||||
selectedEdgeId: null,
|
||||
newEdgeId: null,
|
||||
isLoading: false,
|
||||
@@ -582,6 +586,52 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
||||
};
|
||||
});
|
||||
|
||||
const backgroundRefreshesInFlight = new Set<string>();
|
||||
|
||||
function runBackgroundRefresh(key: string, errorMessage: string, task: () => Promise<void>): void {
|
||||
if (backgroundRefreshesInFlight.has(key)) return;
|
||||
backgroundRefreshesInFlight.add(key);
|
||||
void task()
|
||||
.catch((error) => console.error(errorMessage, error))
|
||||
.finally(() => backgroundRefreshesInFlight.delete(key));
|
||||
}
|
||||
|
||||
function refreshNetworkIpIntelligence(identity: interfaces.data.IIdentity, ipAddresses: string[]): void {
|
||||
const ips = [...new Set(ipAddresses.filter(Boolean))].slice(0, 100);
|
||||
if (ips.length === 0) return;
|
||||
|
||||
runBackgroundRefresh('networkIpIntelligence', 'IP intelligence refresh failed:', async () => {
|
||||
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListIpIntelligence
|
||||
>('/typedrequest', 'listIpIntelligence');
|
||||
const intelligenceResponse = await intelligenceRequest.fire({
|
||||
identity,
|
||||
ipAddresses: ips,
|
||||
limit: Math.max(100, ips.length),
|
||||
});
|
||||
networkStatePart.setState({
|
||||
...networkStatePart.getState()!,
|
||||
ipIntelligence: intelligenceResponse.records || [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSecurityIpIntelligence(identity: interfaces.data.IIdentity): void {
|
||||
runBackgroundRefresh('securityIpIntelligence', 'Security IP intelligence refresh failed:', async () => {
|
||||
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListIpIntelligence
|
||||
>('/typedrequest', 'listIpIntelligence');
|
||||
const intelligenceResponse = await intelligenceRequest.fire({
|
||||
identity,
|
||||
limit: 500,
|
||||
});
|
||||
securityPolicyStatePart.setState({
|
||||
...securityPolicyStatePart.getState()!,
|
||||
ipIntelligence: intelligenceResponse.records || [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch Network Stats Action
|
||||
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg): Promise<INetworkState> => {
|
||||
const context = getActionContext();
|
||||
@@ -594,18 +644,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
interfaces.requests.IReq_GetNetworkStats
|
||||
>('/typedrequest', 'getNetworkStats');
|
||||
|
||||
const ipIntelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListIpIntelligence
|
||||
>('/typedrequest', 'listIpIntelligence');
|
||||
|
||||
const [networkStatsResponse, ipIntelligenceResponse] = await Promise.all([
|
||||
networkStatsRequest.fire({
|
||||
identity: context.identity,
|
||||
}),
|
||||
ipIntelligenceRequest.fire({
|
||||
identity: context.identity,
|
||||
}),
|
||||
]);
|
||||
const networkStatsResponse = await networkStatsRequest.fire({
|
||||
identity: context.identity,
|
||||
});
|
||||
|
||||
// Use the connections data for the connection list
|
||||
// and network stats for throughput and IP analytics
|
||||
@@ -637,6 +678,12 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
};
|
||||
});
|
||||
|
||||
refreshNetworkIpIntelligence(context.identity, [
|
||||
...Object.keys(connectionsByIP),
|
||||
...(networkStatsResponse.topIPs || []).map((item) => item.ip),
|
||||
...(networkStatsResponse.topIPsByBandwidth || []).map((item) => item.ip),
|
||||
]);
|
||||
|
||||
return {
|
||||
connections,
|
||||
connectionsByIP,
|
||||
@@ -646,8 +693,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
: { in: 0, out: 0 },
|
||||
topIPs: networkStatsResponse.topIPs || [],
|
||||
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
|
||||
topASNs: networkStatsResponse.topASNs || [],
|
||||
throughputByIP: networkStatsResponse.throughputByIP || [],
|
||||
ipIntelligence: ipIntelligenceResponse.records || [],
|
||||
ipIntelligence: currentState.ipIntelligence,
|
||||
domainActivity: networkStatsResponse.domainActivity || [],
|
||||
throughputHistory: networkStatsResponse.throughputHistory || [],
|
||||
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
||||
@@ -683,9 +731,6 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
|
||||
const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListSecurityBlockRules
|
||||
>('/typedrequest', 'listSecurityBlockRules');
|
||||
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListIpIntelligence
|
||||
>('/typedrequest', 'listIpIntelligence');
|
||||
const compiledPolicyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetCompiledSecurityPolicy
|
||||
>('/typedrequest', 'getCompiledSecurityPolicy');
|
||||
@@ -693,16 +738,17 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
|
||||
interfaces.requests.IReq_ListSecurityPolicyAudit
|
||||
>('/typedrequest', 'listSecurityPolicyAudit');
|
||||
|
||||
const [rulesResponse, intelligenceResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
|
||||
const [rulesResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
|
||||
rulesRequest.fire({ identity: context.identity }),
|
||||
intelligenceRequest.fire({ identity: context.identity }),
|
||||
compiledPolicyRequest.fire({ identity: context.identity }),
|
||||
auditRequest.fire({ identity: context.identity, limit: 100 }),
|
||||
]);
|
||||
|
||||
refreshSecurityIpIntelligence(context.identity);
|
||||
|
||||
return {
|
||||
rules: rulesResponse.rules || [],
|
||||
ipIntelligence: intelligenceResponse.records || [],
|
||||
ipIntelligence: currentState.ipIntelligence,
|
||||
compiledPolicy: compiledPolicyResponse.policy,
|
||||
auditEvents: auditResponse.events || [],
|
||||
isLoading: false,
|
||||
@@ -835,7 +881,15 @@ export const refreshIpIntelligenceAction = securityPolicyStatePart.createAction<
|
||||
if (!response.success) {
|
||||
return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
|
||||
const refreshedState = await actionContext!.dispatch(fetchSecurityPolicyAction, null);
|
||||
if (!response.record) return refreshedState;
|
||||
return {
|
||||
...refreshedState,
|
||||
ipIntelligence: [
|
||||
response.record,
|
||||
...refreshedState.ipIntelligence.filter((record) => record.ipAddress !== response.record!.ipAddress),
|
||||
],
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
@@ -1042,15 +1096,21 @@ export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(asyn
|
||||
interfaces.requests.IReq_GetRemoteIngressStatus
|
||||
>('/typedrequest', 'getRemoteIngressStatus');
|
||||
|
||||
const [edgesResponse, statusResponse] = await Promise.all([
|
||||
const hubSettingsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetRemoteIngressHubSettings
|
||||
>('/typedrequest', 'getRemoteIngressHubSettings');
|
||||
|
||||
const [edgesResponse, statusResponse, hubSettingsResponse] = await Promise.all([
|
||||
edgesRequest.fire({ identity: context.identity }),
|
||||
statusRequest.fire({ identity: context.identity }),
|
||||
hubSettingsRequest.fire({ identity: context.identity }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
edges: edgesResponse.edges,
|
||||
statuses: statusResponse.statuses,
|
||||
hubSettings: hubSettingsResponse.settings,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
@@ -1068,6 +1128,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
name: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
|
||||
tags?: string[];
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
|
||||
const context = getActionContext();
|
||||
@@ -1083,6 +1144,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
name: dataArg.name,
|
||||
listenPorts: dataArg.listenPorts,
|
||||
autoDerivePorts: dataArg.autoDerivePorts,
|
||||
performance: dataArg.performance,
|
||||
tags: dataArg.tags,
|
||||
});
|
||||
|
||||
@@ -1135,6 +1197,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
name?: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
|
||||
tags?: string[];
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
|
||||
const context = getActionContext();
|
||||
@@ -1151,6 +1214,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
name: dataArg.name,
|
||||
listenPorts: dataArg.listenPorts,
|
||||
autoDerivePorts: dataArg.autoDerivePorts,
|
||||
performance: dataArg.performance,
|
||||
tags: dataArg.tags,
|
||||
});
|
||||
|
||||
@@ -1163,6 +1227,38 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
export const updateRemoteIngressHubSettingsAction = remoteIngressStatePart.createAction<{
|
||||
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateRemoteIngressHubSettings
|
||||
>('/typedrequest', 'updateRemoteIngressHubSettings');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
performance: dataArg.performance,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
return {
|
||||
...currentState,
|
||||
error: response.message || 'Failed to update RemoteIngress hub settings',
|
||||
};
|
||||
}
|
||||
|
||||
return await actionContext!.dispatch(fetchRemoteIngressAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to update RemoteIngress hub settings',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>(
|
||||
async (statePartArg, edgeId): Promise<IRemoteIngressState> => {
|
||||
const context = getActionContext();
|
||||
@@ -1520,6 +1616,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
domains?: string[];
|
||||
targets?: Array<{ ip: string; port: number }>;
|
||||
routeRefs?: string[];
|
||||
allowRoutesByClientSourceIp?: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -1533,6 +1630,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
@@ -1556,6 +1654,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
domains?: string[];
|
||||
targets?: Array<{ ip: string; port: number }>;
|
||||
routeRefs?: string[];
|
||||
allowRoutesByClientSourceIp?: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -1570,6 +1669,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
@@ -3099,6 +3199,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
bwIn: e.bandwidth?.in || 0,
|
||||
bwOut: e.bandwidth?.out || 0,
|
||||
})),
|
||||
topASNs: network.topASNs || [],
|
||||
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
|
||||
domainActivity: network.domainActivity || [],
|
||||
throughputHistory: network.throughputHistory || [],
|
||||
@@ -3112,53 +3213,38 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListIpIntelligence
|
||||
>('/typedrequest', 'listIpIntelligence');
|
||||
const intelligenceResponse = await intelligenceRequest.fire({ identity: context.identity });
|
||||
networkStatePart.setState({
|
||||
...networkStatePart.getState()!,
|
||||
ipIntelligence: intelligenceResponse.records || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('IP intelligence refresh failed:', error);
|
||||
}
|
||||
refreshNetworkIpIntelligence(context.identity, [
|
||||
...network.connectionDetails.map((conn) => conn.remoteAddress),
|
||||
...network.topEndpoints.map((endpoint) => endpoint.endpoint),
|
||||
...(network.topEndpointsByBandwidth || []).map((endpoint) => endpoint.endpoint),
|
||||
]);
|
||||
}
|
||||
|
||||
if (currentView === 'security') {
|
||||
try {
|
||||
runBackgroundRefresh('securityPolicy', 'Security policy refresh failed:', async () => {
|
||||
await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null);
|
||||
} catch (error) {
|
||||
console.error('Security policy refresh failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh certificate data if on Domains > Certificates subview
|
||||
if (currentView === 'domains' && currentSubview === 'certificates') {
|
||||
try {
|
||||
runBackgroundRefresh('certificates', 'Certificate refresh failed:', async () => {
|
||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||
} catch (error) {
|
||||
console.error('Certificate refresh failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh remote ingress data if on the Network → Remote Ingress subview
|
||||
if (currentView === 'network' && currentSubview === 'remoteingress') {
|
||||
try {
|
||||
runBackgroundRefresh('remoteIngress', 'Remote ingress refresh failed:', async () => {
|
||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||
} catch (error) {
|
||||
console.error('Remote ingress refresh failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh VPN data if on the Network → VPN subview
|
||||
if (currentView === 'network' && currentSubview === 'vpn') {
|
||||
try {
|
||||
runBackgroundRefresh('vpn', 'VPN refresh failed:', async () => {
|
||||
await vpnStatePart.dispatchAction(fetchVpnAction, null);
|
||||
} catch (error) {
|
||||
console.error('VPN refresh failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Combined refresh failed:', error);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user