Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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();
|
||||
+239
@@ -3,8 +3,247 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.41.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.41.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/tsdocker": "^2.4.0",
|
||||
"@git.zone/tsdeno": "^1.5.0",
|
||||
"@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.3",
|
||||
"@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.4",
|
||||
"@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.3
|
||||
version: 27.12.3
|
||||
'@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.4
|
||||
version: 4.22.4
|
||||
'@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.0
|
||||
version: 2.4.0
|
||||
'@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.0':
|
||||
resolution: {integrity: sha512-GFE93RxFm8HDrSm5Ulggy4se7heb4GaNQgaWV6Mds6lhkm6GouO91xZYlmXVH9glzBoFJNG63pFXYHW6nrqf5A==}
|
||||
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.3':
|
||||
resolution: {integrity: sha512-nw5+iYhngwrdmSOg87R1opHVZXdLK4GHm/PAtVSWHD7zlnOPhEvdrlJndAq4ehGktf7z6B0SvwwmdrAOCPhWWw==}
|
||||
|
||||
'@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.4':
|
||||
resolution: {integrity: sha512-3SPTlFQQlB7ptdUr0TzZJQ1UOppPWcjcffv25qpO64gzw5f5VhmkywN7YQGAeXqCe4UeuRZrxOwZY0m9SpfJzw==}
|
||||
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.0':
|
||||
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.3':
|
||||
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.4':
|
||||
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
|
||||
```
|
||||
@@ -196,6 +210,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 +274,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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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,182 @@
|
||||
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 {
|
||||
find: (query: Record<string, any> = {}) => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const document of documents) {
|
||||
if (matchesQuery(document, query)) {
|
||||
yield structuredClone(document);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
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 runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.40.2');
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.currentVersionBefore).toEqual('13.16.0');
|
||||
expect(result.currentVersionAfter).toEqual('13.40.2');
|
||||
expect(result.stepsApplied).toHaveLength(3);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.41.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();
|
||||
|
||||
@@ -281,6 +281,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(
|
||||
@@ -293,10 +294,9 @@ export class ReferenceResolver {
|
||||
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();
|
||||
@@ -445,10 +445,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
|
||||
// =========================================================================
|
||||
|
||||
@@ -11,8 +11,7 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
||||
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
||||
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
|
||||
|
||||
export interface IRouteMutationResult {
|
||||
success: boolean;
|
||||
@@ -57,7 +56,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 +72,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,6 +175,8 @@ export class RouteConfigManager {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
|
||||
const previousSourceProfileRef = stored.metadata?.sourceProfileRef;
|
||||
|
||||
const isToggleOnlyPatch = patch.enabled !== undefined
|
||||
&& patch.route === undefined
|
||||
&& patch.metadata === undefined;
|
||||
@@ -217,6 +218,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
|
||||
@@ -608,21 +616,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,131 @@ export interface IMigrationRunner {
|
||||
run(): Promise<IMigrationRunResult>;
|
||||
}
|
||||
|
||||
type TMigrationSecurity = Record<string, any>;
|
||||
|
||||
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 };
|
||||
@@ -89,6 +214,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 +292,12 @@ 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);
|
||||
});
|
||||
|
||||
return migration;
|
||||
|
||||
@@ -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.41.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);
|
||||
|
||||
@@ -200,26 +200,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
private async showCreateTokenDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const allScopes = [
|
||||
'*',
|
||||
'routes:read',
|
||||
'routes:write',
|
||||
'config:read',
|
||||
'certificates:read',
|
||||
'certificates:write',
|
||||
'tokens:read',
|
||||
'tokens:manage',
|
||||
'domains:read',
|
||||
'domains:write',
|
||||
'dns-records:read',
|
||||
'dns-records:write',
|
||||
'email-domains:read',
|
||||
'email-domains:write',
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
];
|
||||
const allScopes = [...interfaces.data.apiTokenScopes];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Create API Token',
|
||||
|
||||
@@ -308,6 +308,9 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
<!-- Top IPs by Connection Count -->
|
||||
${this.renderTopIPs()}
|
||||
|
||||
<!-- Top ASNs by Connection Count -->
|
||||
${this.renderTopASNs()}
|
||||
|
||||
<!-- Top IPs by Bandwidth -->
|
||||
${this.renderTopIPsByBandwidth()}
|
||||
|
||||
@@ -450,6 +453,28 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
];
|
||||
}
|
||||
|
||||
private getAsnDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Block ASN',
|
||||
iconName: 'lucide:radio-tower',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.createBlockRuleDialog('asn', String(actionData.item.asn), 'Blocked ASN from Network Activity');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block Organization',
|
||||
iconName: 'lucide:building-2',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.organization),
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.createBlockRuleDialog('organization', actionData.item.organization, 'Blocked organization from Network Activity');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private calculateThroughput(): { in: number; out: number } {
|
||||
// Use real throughput data from network state
|
||||
return {
|
||||
@@ -619,6 +644,40 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTopASNs(): TemplateResult {
|
||||
if (!this.networkState.topASNs || this.networkState.topASNs.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${this.networkState.topASNs}
|
||||
.rowKey=${'asn'}
|
||||
.highlightUpdates=${'flash'}
|
||||
.displayFunction=${(asnData: appstate.INetworkState['topASNs'][number]) => {
|
||||
return {
|
||||
'ASN': `AS${asnData.asn}`,
|
||||
'Organization': this.formatOptional(asnData.organization),
|
||||
'Connections': asnData.activeConnections,
|
||||
'IPs': asnData.ipCount,
|
||||
'Bandwidth In': this.formatBitsPerSecond(asnData.bytesInPerSecond),
|
||||
'Bandwidth Out': this.formatBitsPerSecond(asnData.bytesOutPerSecond),
|
||||
'Total Bandwidth': this.formatBitsPerSecond(asnData.bytesInPerSecond + asnData.bytesOutPerSecond),
|
||||
'Country': this.formatOptional(asnData.country),
|
||||
'Sample IPs': asnData.sampleIps.join(', '),
|
||||
};
|
||||
}}
|
||||
.dataActions=${this.getAsnDataActions()}
|
||||
heading1="Top Connected ASNs"
|
||||
heading2="Organizations causing the most active connections across observed IPs"
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
.pagination=${false}
|
||||
dataName="ASN"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTopIPsByBandwidth(): TemplateResult {
|
||||
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
|
||||
return html``;
|
||||
|
||||
@@ -12,6 +12,17 @@ import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
const performanceProfileOptions = [
|
||||
{ key: '', option: 'Default' },
|
||||
{ key: 'balanced', option: 'Balanced' },
|
||||
{ key: 'throughput', option: 'Throughput' },
|
||||
{ key: 'highConcurrency', option: 'High concurrency' },
|
||||
];
|
||||
|
||||
function getDropdownKey(value: any): string {
|
||||
return typeof value === 'string' ? value : value?.key || '';
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-remoteingress': OpsViewRemoteIngress;
|
||||
@@ -137,6 +148,13 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
.metricMuted {
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.settingsNote {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -242,6 +260,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
publicIp: this.getEdgePublicIp(edge.id),
|
||||
ports: this.getPortsHtml(edge),
|
||||
tunnels: this.getEdgeTunnelCount(edge.id),
|
||||
maxConnections: this.getMaxConnectionsHtml(edge),
|
||||
window: this.getWindowHtml(edge.id),
|
||||
queues: this.getQueuesHtml(edge.id),
|
||||
traffic: this.getTrafficHtml(edge.id),
|
||||
@@ -261,6 +280,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers, optional'}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
|
||||
<dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated, optional'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -284,12 +304,20 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
|
||||
: undefined;
|
||||
const autoDerivePorts = formData.autoDerivePorts !== false;
|
||||
let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
|
||||
try {
|
||||
performance = this.collectPerformanceOverride(formData);
|
||||
} catch (err: unknown) {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
|
||||
return;
|
||||
}
|
||||
const tags = formData.tags
|
||||
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.createRemoteIngressAction,
|
||||
{ name, listenPorts, autoDerivePorts, tags },
|
||||
{ name, listenPorts, autoDerivePorts, performance, tags },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
@@ -298,6 +326,14 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Hub Settings',
|
||||
iconName: 'lucide:slidersHorizontal',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.showHubSettingsDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Enable',
|
||||
iconName: 'lucide:play',
|
||||
@@ -338,6 +374,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
|
||||
<dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'} .value=${edge.performance?.maxStreamsPerEdge?.toString() || ''}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -359,6 +396,14 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
|
||||
: [];
|
||||
const autoDerivePorts = formData.autoDerivePorts !== false;
|
||||
let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
|
||||
try {
|
||||
performance = this.collectPerformanceOverride(formData, edge.performance);
|
||||
} catch (err: unknown) {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
|
||||
return;
|
||||
}
|
||||
const tags = formData.tags
|
||||
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
@@ -369,6 +414,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
name: formData.name || edge.name,
|
||||
listenPorts,
|
||||
autoDerivePorts,
|
||||
performance,
|
||||
tags,
|
||||
},
|
||||
);
|
||||
@@ -475,6 +521,19 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
return status?.activeTunnels || 0;
|
||||
}
|
||||
|
||||
private getMaxConnectionsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edge.id);
|
||||
const override = edge.performance?.maxStreamsPerEdge;
|
||||
const effective = status?.performance?.maxStreamsPerEdge;
|
||||
if (!override && !effective) return '-';
|
||||
return html`
|
||||
<div class="metricStack">
|
||||
<span>${override || effective}</span>
|
||||
<span class="metricMuted">${override ? 'edge override' : 'hub default'}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getTransportHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected) return '-';
|
||||
@@ -535,4 +594,165 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
}
|
||||
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
private collectPerformanceOverride(
|
||||
formData: Record<string, any>,
|
||||
base?: interfaces.data.IRemoteIngressPerformanceConfig,
|
||||
): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
|
||||
const next: interfaces.data.IRemoteIngressPerformanceConfig = { ...(base || {}) };
|
||||
const maxStreamsText = `${formData.maxStreamsPerEdge || ''}`.trim();
|
||||
if (maxStreamsText) {
|
||||
const maxStreamsPerEdge = Number.parseInt(maxStreamsText, 10);
|
||||
if (!Number.isInteger(maxStreamsPerEdge) || maxStreamsPerEdge < 1) {
|
||||
throw new Error('Max Connections must be a positive integer');
|
||||
}
|
||||
next.maxStreamsPerEdge = maxStreamsPerEdge;
|
||||
} else {
|
||||
delete next.maxStreamsPerEdge;
|
||||
}
|
||||
|
||||
if (Object.keys(next).length > 0) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return base ? {} : undefined;
|
||||
}
|
||||
|
||||
private async showHubSettingsDialog(): Promise<void> {
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
const performance = this.riState.hubSettings?.performance || {};
|
||||
const selectedProfile = performanceProfileOptions.find((option) => option.key === (performance.profile || '')) || performanceProfileOptions[0];
|
||||
const updatedAt = this.riState.hubSettings?.updatedAt
|
||||
? new Date(this.riState.hubSettings.updatedAt).toLocaleString()
|
||||
: 'not persisted yet';
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'RemoteIngress Hub Settings',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-dropdown
|
||||
.key=${'profile'}
|
||||
.label=${'Performance Profile'}
|
||||
.options=${performanceProfileOptions}
|
||||
.selectedOption=${selectedProfile}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text
|
||||
.key=${'maxStreamsPerEdge'}
|
||||
.label=${'Max Connections / Edge'}
|
||||
.description=${'Maximum concurrent client streams per edge. Leave empty for RemoteIngress defaults.'}
|
||||
.value=${performance.maxStreamsPerEdge?.toString() || ''}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'clientWriteTimeoutMs'}
|
||||
.label=${'Client Write Timeout'}
|
||||
.description=${'Milliseconds before idle client writes are timed out. Leave empty for default.'}
|
||||
.value=${performance.clientWriteTimeoutMs?.toString() || ''}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'firstDataConnectTimeoutMs'}
|
||||
.label=${'First Data Timeout'}
|
||||
.description=${'Milliseconds to wait for initial client data before connecting upstream. Leave empty for default.'}
|
||||
.value=${performance.firstDataConnectTimeoutMs?.toString() || ''}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'serverFirstPorts'}
|
||||
.label=${'Server-first Ports'}
|
||||
.description=${'Comma-separated ports such as 21, 22, 25, 110, 143, 587. Do not include 443.'}
|
||||
.value=${(performance.serverFirstPorts || []).join(', ')}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
<p class="settingsNote">
|
||||
Saving restarts the RemoteIngress hub so connected edges reconnect and pick up the new defaults.
|
||||
Last updated: ${updatedAt} by ${this.riState.hubSettings?.updatedBy || 'default'}.
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
let performanceSettings: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
|
||||
try {
|
||||
performanceSettings = this.collectHubPerformanceSettings(formData);
|
||||
} catch (err: unknown) {
|
||||
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.updateRemoteIngressHubSettingsAction,
|
||||
{ performance: performanceSettings },
|
||||
);
|
||||
if (nextState.error) {
|
||||
DeesToast.show({ message: nextState.error, type: 'error', duration: 4000 });
|
||||
return;
|
||||
}
|
||||
await modalArg.destroy();
|
||||
DeesToast.show({ message: 'RemoteIngress hub settings saved', type: 'success', duration: 3000 });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private collectHubPerformanceSettings(formData: Record<string, any>): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
|
||||
const next: interfaces.data.IRemoteIngressPerformanceConfig = {};
|
||||
const profile = getDropdownKey(formData.profile) as interfaces.data.TRemoteIngressPerformanceProfile | '';
|
||||
if (profile) {
|
||||
next.profile = profile;
|
||||
}
|
||||
|
||||
this.assignPositiveIntegerSetting(next, 'maxStreamsPerEdge', formData.maxStreamsPerEdge, 'Max Connections / Edge');
|
||||
this.assignPositiveIntegerSetting(next, 'clientWriteTimeoutMs', formData.clientWriteTimeoutMs, 'Client Write Timeout');
|
||||
this.assignPositiveIntegerSetting(next, 'firstDataConnectTimeoutMs', formData.firstDataConnectTimeoutMs, 'First Data Timeout');
|
||||
|
||||
const serverFirstPorts = this.parsePortList(formData.serverFirstPorts, 'Server-first Ports');
|
||||
if (serverFirstPorts.length > 0) {
|
||||
if (serverFirstPorts.includes(443)) {
|
||||
throw new Error('Port 443 is client-first TLS and must not be listed as server-first');
|
||||
}
|
||||
next.serverFirstPorts = serverFirstPorts;
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
private assignPositiveIntegerSetting(
|
||||
target: interfaces.data.IRemoteIngressPerformanceConfig,
|
||||
key: 'maxStreamsPerEdge' | 'clientWriteTimeoutMs' | 'firstDataConnectTimeoutMs',
|
||||
value: any,
|
||||
label: string,
|
||||
): void {
|
||||
const text = `${value || ''}`.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(text, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${label} must be a positive integer`);
|
||||
}
|
||||
target[key] = parsed;
|
||||
}
|
||||
|
||||
private parsePortList(value: any, label: string): number[] {
|
||||
const text = `${value || ''}`.trim();
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const ports = text.split(',').map((part) => Number.parseInt(part.trim(), 10));
|
||||
for (const port of ports) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`${label} must contain valid port numbers`);
|
||||
}
|
||||
}
|
||||
return [...new Set(ports)].sort((a, b) => a - b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
'Route Refs': profile.routeRefs?.length
|
||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
|
||||
: '-',
|
||||
'Source-Policy Route Grants': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No',
|
||||
Created: new Date(profile.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
.dataActions=${[
|
||||
@@ -223,6 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${false}></dees-input-checkbox>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -258,6 +260,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
domains: domains.length > 0 ? domains : undefined,
|
||||
targets: targets.length > 0 ? targets : undefined,
|
||||
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
|
||||
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
@@ -284,6 +287,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
|
||||
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -319,6 +323,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
domains,
|
||||
targets,
|
||||
routeRefs,
|
||||
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
@@ -389,6 +394,10 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Client Source IP Routes</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">${profile.allowRoutesByClientSourceIp ? 'Enabled' : 'Disabled'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}</div>
|
||||
|
||||
@@ -339,6 +339,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
'Status': statusHtml,
|
||||
'Routing': routingHtml,
|
||||
'VPN IP': client.assignedIp || '-',
|
||||
'Source IP': conn?.sourceIp || '-',
|
||||
'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
|
||||
'Description': client.description || '-',
|
||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||
@@ -487,6 +488,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
${conn ? html`
|
||||
<div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Source IP</span><span class="infoValue">${conn.sourceIp || '-'}</span></div>
|
||||
` : ''}
|
||||
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
||||
|
||||
@@ -304,6 +304,16 @@ export class OpsViewConfig extends DeesElement {
|
||||
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
|
||||
];
|
||||
|
||||
if (ri.performance) {
|
||||
fields.push(
|
||||
{ key: 'Performance Profile', value: ri.performance.profile || null, type: 'badge' },
|
||||
{ key: 'Max Connections / Edge', value: ri.performance.maxStreamsPerEdge ?? null },
|
||||
{ key: 'Client Write Timeout', value: ri.performance.clientWriteTimeoutMs ? `${ri.performance.clientWriteTimeoutMs} ms` : null },
|
||||
{ key: 'First Data Timeout', value: ri.performance.firstDataConnectTimeoutMs ? `${ri.performance.firstDataConnectTimeoutMs} ms` : null },
|
||||
{ key: 'Server-first Ports', value: ri.performance.serverFirstPorts?.length ? ri.performance.serverFirstPorts.map(String) : null, type: 'pills' },
|
||||
);
|
||||
}
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'remoteingress' } },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user