Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2a10bdc3c | |||
| 42a5f6df7b | |||
| c61d832b43 | |||
| 872a822ed7 | |||
| 34bfd1528b | |||
| be38808795 | |||
| b9ae4ac344 | |||
| 37adcc9ddc | |||
| ac118397f9 | |||
| 8188b4712c | |||
| 27d077feed | |||
| 98913c1977 | |||
| ca5c57a329 | |||
| 707fbc2413 | |||
| a0c9d40e87 | |||
| 2a73973eda | |||
| f0069f87e2 | |||
| 77c1738390 | |||
| 53d7c5350e | |||
| 7986d01245 | |||
| 0b01a4c26b | |||
| 407c8eef8a | |||
| aa0ef2f033 | |||
| 7819f09625 | |||
| 3f8c0c4219 | |||
| 70fcd46d52 | |||
| 47a1f5d7db | |||
| 67b9fb536c | |||
| 8dd0c3def9 | |||
| d73b250382 | |||
| 1c1d55ab8a | |||
| 2596303c06 | |||
| f78bddaede | |||
| a2887d6266 | |||
| 97505935bb | |||
| 7e3b89d9b4 | |||
| 7bb6559748 | |||
| 5fbe2eb80b | |||
| a22cc1c0eb | |||
| 4ea339b85a | |||
| df9cc3e49b | |||
| 7f3ab2499d | |||
| 89ab918826 | |||
| e5c3578163 | |||
| 1567606c49 | |||
| af31982d58 |
@@ -1,4 +1,4 @@
|
||||
name: Docker (tags)
|
||||
name: Docker (non-tag pushes)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,42 +8,10 @@ on:
|
||||
env:
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
|
||||
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm and npmci
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
@@ -54,18 +22,14 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
pnpm install -g @git.zone/tsdocker@latest
|
||||
pnpm install
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci npm prepare
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci command npm run build
|
||||
- name: Build image
|
||||
run: tsdocker build
|
||||
|
||||
- name: Test image
|
||||
run: tsdocker test
|
||||
|
||||
@@ -8,73 +8,13 @@ on:
|
||||
env:
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
|
||||
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci command npm run build
|
||||
|
||||
release:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:dbase_dind
|
||||
image: code.foss.global/host.today/ht-docker-dbase:szci
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -82,23 +22,20 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @git.zone/tsdocker
|
||||
pnpm install -g @git.zone/tsdocker@latest
|
||||
pnpm install
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
tsdocker login
|
||||
tsdocker build
|
||||
tsdocker push
|
||||
- name: Login to registries
|
||||
run: tsdocker login
|
||||
|
||||
metadata:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
- name: List images
|
||||
run: tsdocker list
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build images
|
||||
run: tsdocker build
|
||||
|
||||
- name: Trigger
|
||||
run: npmci trigger
|
||||
- name: Test images
|
||||
run: tsdocker test
|
||||
|
||||
- name: Push to code.foss.global
|
||||
run: tsdocker push code.foss.global
|
||||
|
||||
+33
-19
@@ -23,14 +23,17 @@
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true,
|
||||
"includeFiles": ["./html/**/*.html"]
|
||||
"includeFiles": [
|
||||
"./html/**/*.html"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "dcrouter",
|
||||
"description": "A traffic router intended to be gating your datacenter.",
|
||||
@@ -60,26 +63,37 @@
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"npm": {
|
||||
"enabled": true,
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||
},
|
||||
"npmRegistryUrl": "verdaccio.lossless.digital"
|
||||
},
|
||||
"@git.zone/tsdocker": {
|
||||
"registries": ["code.foss.global"],
|
||||
"registries": [
|
||||
"code.foss.global"
|
||||
],
|
||||
"registryRepoMap": {
|
||||
"code.foss.global": "serve.zone/dcrouter",
|
||||
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
|
||||
"code.foss.global": "serve.zone/dcrouter"
|
||||
},
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
"platforms": [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
+10
-6
@@ -1,12 +1,18 @@
|
||||
# gitzone dockerfile_service
|
||||
## STAGE 1 // BUILD
|
||||
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||
COPY ./ /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm config set registry https://verdaccio.lossless.digital/
|
||||
RUN pnpm config set store-dir .pnpm-store
|
||||
RUN rm -rf node_modules && pnpm install
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . ./
|
||||
RUN pnpm run build
|
||||
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
|
||||
RUN rm -rf .pnpm-store
|
||||
RUN pnpm prune --prod
|
||||
|
||||
## STAGE 2 // PRODUCTION
|
||||
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
|
||||
@@ -18,12 +24,10 @@ WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
|
||||
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||
ENV NODE_ENV=production
|
||||
ENV DCROUTER_HEAP_SIZE=512
|
||||
ENV UV_THREADPOOL_SIZE=16
|
||||
|
||||
RUN pnpm install -g @servezone/healthy
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
|
||||
|
||||
LABEL org.opencontainers.image.title="dcrouter" \
|
||||
org.opencontainers.image.description="Multi-service datacenter gateway" \
|
||||
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
|
||||
|
||||
+192
-1
@@ -1,5 +1,196 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-29 - 13.36.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- preserve parallel ACME DNS-01 TXT challenges and consume case-insensitive DNS matching (dns,certificates)
|
||||
- Keep exact and wildcard SAN challenge TXT records at the same owner name instead of deleting sibling challenge values.
|
||||
- Match local dcrouter-hosted DNS records case-insensitively so DNS 0x20 mixed-case queries keep resolving.
|
||||
- Update @push.rocks/smartdns to 7.9.3 for case-insensitive handler matching in the embedded DNS server.
|
||||
- preserve parallel ACME TXT challenges and mixed-case DNS queries (dns)
|
||||
- Remove only matching ACME DNS-01 TXT challenge values during setup and cleanup so parallel challenges can coexist.
|
||||
- Resolve locally hosted DNS records case-insensitively while preserving the query name casing in responses.
|
||||
- Bump @push.rocks/smartdns to ^7.9.3.
|
||||
|
||||
## 2026-05-28 - 13.36.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- consume RemoteIngress 4.18.0 tunnel performance improvements (remoteingress)
|
||||
- Update @serve.zone/remoteingress to 4.18.0 so DcRouter uses zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix.
|
||||
- bump @serve.zone/remoteingress to ^4.18.0 (remoteingress)
|
||||
- Updates @serve.zone/remoteingress from ^4.17.1 to ^4.18.0.
|
||||
- Consumes zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix from RemoteIngress.
|
||||
|
||||
## 2026-05-28 - 13.36.0
|
||||
|
||||
### Features
|
||||
|
||||
- add top connected ASN activity to Network Activity (network)
|
||||
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
|
||||
- Expose ASN activity through network stats and combined metrics APIs.
|
||||
- Add a Network Activity table with ASN and organization block actions.
|
||||
- Add MetricsManager coverage for ASN aggregation.
|
||||
- add top connected ASN activity to network monitoring (network)
|
||||
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
|
||||
- Expose top ASN activity through network stats and combined metrics API responses.
|
||||
- Add a Network Activity table for top ASNs with ASN and organization block actions.
|
||||
- Add MetricsManager coverage for ASN aggregation.
|
||||
|
||||
## 2026-05-24 - 13.35.0
|
||||
|
||||
### Features
|
||||
|
||||
- switch VPN route authorization to authenticated SmartVPN metadata (vpn)
|
||||
- configure SmartVPN to forward real client source IPs plus VPN metadata through trusted PROXY v2 headers
|
||||
- map target profiles to SmartProxy VPN client grants instead of mutating route source IP allow lists
|
||||
- keep live VPN client source IP tracking as status/UI data while SmartProxy enforces source policy per connection
|
||||
|
||||
## 2026-05-21 - 13.34.0
|
||||
|
||||
### Features
|
||||
|
||||
- allow VPN target profiles to grant routes by live client source IP (vpn)
|
||||
- Add an opt-in target profile flag that evaluates non-vpnOnly route source security against the VPN client's real connecting IP.
|
||||
- Track live VPN client source IPs from smartvpn remote addresses and WireGuard peer endpoints, refreshing routes when they change.
|
||||
- Expose the setting and current source IPs in the Ops UI with regression coverage for source-IP matching behavior.
|
||||
- allow target profiles to grant non-vpnOnly routes by live client source IP (vpn)
|
||||
- add an opt-in target profile flag to match route source security against a VPN client's real connecting IP
|
||||
- track live client source IPs from VPN remote addresses and WireGuard peer endpoints and re-apply routes when they change
|
||||
- expose source IP access settings and current client source IPs through the ops API and UI
|
||||
- add regression tests for source-IP route matching, block-list handling, vpnOnly exclusions, and WireGuard endpoint refresh
|
||||
|
||||
## 2026-05-21 - 13.33.0
|
||||
|
||||
### Features
|
||||
|
||||
- add queued IP intelligence observation and filtered retrieval for network and security views (security)
|
||||
- Queue observed public IPs from network metrics with throttled background enrichment instead of awaiting lookups during stats collection.
|
||||
- Allow listing IP intelligence records by specific IP addresses and limit through the security handler and request interface.
|
||||
- Update web app state to refresh IP intelligence asynchronously in the background and preserve current UI state during refreshes.
|
||||
- Improve security policy manager observation handling so forced refresh waits for in-flight lookups before fetching updated intelligence.
|
||||
|
||||
## 2026-05-20 - 13.32.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules (opsserver,vpn)
|
||||
- Block ephemeral admin bootstrap login and user listing until the configured database is ready, and report bootstrap availability accurately in admin status responses.
|
||||
- Preserve persisted admin accounts across OpsServer restarts with added regression coverage.
|
||||
- Merge matching VPN client IPs into restricted non-vpnOnly route allow lists without duplicating entries.
|
||||
- Handle string and wildcard route domains consistently when resolving target profile access and VPN client matches.
|
||||
|
||||
## 2026-05-19 - 13.32.0
|
||||
|
||||
### Features
|
||||
|
||||
- add scoped API token auth across ops endpoints (ops-auth)
|
||||
- introduces a shared requireOpsAuth helper that validates JWT identities and API tokens with scope and admin-policy checks
|
||||
- applies explicit per-endpoint authorization across config, logs, stats, security, VPN, RADIUS, remote ingress, users, API tokens, and related ops handlers
|
||||
- extends request interfaces and UI scope definitions to support apiToken-based access and adds tests for auth behavior and migration bridging
|
||||
|
||||
## 2026-05-19 - 13.31.0
|
||||
|
||||
### Features
|
||||
|
||||
- add admin user create/delete management and default hosted idp.global auth support (opsserver)
|
||||
- adds admin-only createUser and deleteUser typed requests with safeguards against deleting the current user or last active admin
|
||||
- updates the ops users UI to create and delete users, show richer account details, and support optional idp.global login during account creation
|
||||
- treats idp.global as available by default via the hosted https://idp.global endpoint while keeping URL settings as optional overrides
|
||||
- adds VPN-only route controls and indicators in the ops routes UI
|
||||
|
||||
## 2026-05-18 - 13.30.0
|
||||
|
||||
### Features
|
||||
|
||||
- document first-admin bootstrap flow and update authentication examples (docs)
|
||||
- Add README guidance for explicit initial admin creation on DB-backed instances across the main package, API client, interfaces, and web dashboard docs.
|
||||
- Update authentication examples to use persisted admin email/password credentials instead of the old default admin login.
|
||||
- Refresh dependency versions in package.json to align documentation with current package releases.
|
||||
|
||||
## 2026-05-14 - 13.29.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- enable npm publishing in smartconfig (smartconfig)
|
||||
- Sets the npm integration flag to true in .smartconfig.json
|
||||
- Keeps the configured Verdaccio and npmjs registries unchanged
|
||||
|
||||
## 2026-05-14 - 13.29.0
|
||||
|
||||
### Fixes
|
||||
|
||||
- harden VPN route access and wireguard client configuration handling (vpn)
|
||||
- Fail closed for vpnOnly routes when no VPN client IPs are available by replacing allow lists and enforcing a block-all fallback
|
||||
- Refresh route application and VPN client security after target profile creation so profile changes take effect immediately
|
||||
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
|
||||
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
|
||||
|
||||
### Features
|
||||
|
||||
- add persisted admin bootstrap flow with optional idp.global authentication (opsserver-admin)
|
||||
- introduces bootstrap status and initial admin creation endpoints for OpsServer
|
||||
- switches admin authentication from ephemeral-only users to database-backed accounts when a persistent admin exists
|
||||
- adds optional idp.global login support for admin accounts and exposes auth source metadata in user listings
|
||||
- updates the web dashboard to prompt creation of the first persisted admin account
|
||||
- adds integration coverage for bootstrap, persisted login, identity invalidation, and user listing behavior
|
||||
|
||||
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
|
||||
add managed gateway client administration and token-bound route ownership
|
||||
|
||||
- introduce persistent gateway client management with create, update, delete, list, and scoped token creation flows
|
||||
- add gateway client context and ownership resolution so token-bound clients can sync routes without spoofing another client
|
||||
- surface gateway client administration in the ops dashboard with a new Access > Gateway Clients view
|
||||
- mark certificate provisioning backoff failures as failed and expose root-cause errors with DNS management guidance in the certificates view
|
||||
|
||||
## 2026-05-09 - 13.27.1 - fix(docker)
|
||||
configure pnpm to use the verdaccio registry during Docker builds
|
||||
|
||||
- Adds a pnpm registry configuration step before dependency installation in the Dockerfile.
|
||||
- Ensures container builds resolve packages from the configured Verdaccio registry.
|
||||
|
||||
## 2026-05-09 - 13.27.0 - feat(api-token-manager)
|
||||
seed and rotate the environment-managed admin API token during initialization
|
||||
|
||||
- Add initialization support for DCROUTER_ADMIN_API_TOKEN with validation, persistence, and admin policy assignment
|
||||
- Ensure the environment-managed token is updated when the configured raw token changes
|
||||
- Refactor token hashing into a shared helper and add coverage for seeding, validation, redaction, and rotation behavior
|
||||
|
||||
## 2026-05-09 - 13.26.0 - feat(gateway-clients)
|
||||
add policy-based gateway client tokens and gateway client route and DNS management endpoints
|
||||
|
||||
- Introduces API token policies with admin and gatewayClient roles, capability checks, hostname restrictions, and allowed route targets.
|
||||
- Adds gateway client request and data interfaces for domains, DNS records, route sync, and ownership metadata while keeping workhoster aliases for compatibility.
|
||||
- Extends route metadata normalization to prefer gatewayClient ownership and updates generated route names and test coverage accordingly.
|
||||
|
||||
## 2026-04-26 - 13.25.0 - feat(security)
|
||||
compile network ranges and CIDR arrays into edge firewall policies
|
||||
|
||||
- add support for storing intelligence network CIDR arrays alongside single network ranges
|
||||
- convert start-end IPv4 ranges into CIDR blocks when compiling security policies
|
||||
- always return an explicit remote ingress firewall snapshot with a blockedIps array
|
||||
- add tests covering range normalization, ASN-derived CIDRs, and empty firewall snapshots
|
||||
|
||||
## 2026-04-26 - 13.24.0 - feat(security)
|
||||
add security policy management and IP intelligence operations to the ops UI
|
||||
|
||||
- adds typed request endpoints to fetch compiled security policy, list audit events, and force-refresh IP intelligence
|
||||
- introduces dedicated security policy state and actions for loading, creating, updating, deleting, and refreshing security data
|
||||
- enhances the network activity view with IP intelligence columns, detail dialogs, and block-rule actions
|
||||
- expands the security blocked view into a full management interface for rules, compiled policy, IP intelligence, and audit history
|
||||
|
||||
## 2026-04-26 - 13.23.0 - feat(security)
|
||||
add managed security policies with IP intelligence and remote ingress firewall propagation
|
||||
|
||||
- introduces a SecurityPolicyManager that observes public IPs, stores IP intelligence, compiles block policies, and audits policy changes
|
||||
- adds database documents and shared interfaces for security block rules, IP intelligence records, and security policy audit events
|
||||
- exposes ops/admin request handlers to list IP intelligence and create, update, or delete security block rules
|
||||
- applies merged security policies to SmartProxy and propagates firewall snapshots to remote ingress edges and tunnel synchronization
|
||||
|
||||
## 2026-04-26 - 13.22.0 - feat(remoteingress)
|
||||
add remote ingress performance configuration and expose tunnel transport metrics
|
||||
|
||||
@@ -2560,4 +2751,4 @@ Applied a core fix.
|
||||
- Fixed core functionality for version 1.0.1
|
||||
|
||||
–––––––––––––––––––––––
|
||||
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
|
||||
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
|
||||
|
||||
+31
-29
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.22.0",
|
||||
"version": "13.36.2",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -22,54 +22,56 @@
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.6.0"
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdocker": "^2.4.0",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@types/node": "^25.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
"@api.global/typedrequest": "^3.3.1",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@api.global/typedsocket": "^4.1.3",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.78.2",
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@idp.global/sdk": "^1.3.1",
|
||||
"@push.rocks/lik": "^6.4.1",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/qenv": "^6.1.4",
|
||||
"@push.rocks/smartacme": "^9.5.0",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartdb": "^2.6.2",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartdb": "^2.10.1",
|
||||
"@push.rocks/smartdns": "^7.9.3",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartjwt": "^2.2.2",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmigration": "1.2.0",
|
||||
"@push.rocks/smartmigration": "1.4.1",
|
||||
"@push.rocks/smartmta": "^5.3.3",
|
||||
"@push.rocks/smartnetwork": "^4.6.0",
|
||||
"@push.rocks/smartnetwork": "^4.7.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.8.2",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartproxy": "^27.11.0",
|
||||
"@push.rocks/smartradius": "^1.1.2",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartstate": "^2.3.1",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.2",
|
||||
"@push.rocks/smartvpn": "1.20.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.4.3",
|
||||
"@serve.zone/remoteingress": "^4.17.0",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@serve.zone/interfaces": "^5.8.0",
|
||||
"@serve.zone/remoteingress": "^4.18.0",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.3.5",
|
||||
"lru-cache": "^11.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
|
||||
Generated
+2222
-2130
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,38 @@
|
||||
# @serve.zone/dcrouter
|
||||
|
||||

|
||||
|
||||
`dcrouter` is a TypeScript control plane for running a serious multi-protocol edge or datacenter gateway from one process. It wires together SmartProxy for HTTP/HTTPS/TCP routing, smartmta for email, smartdns for authoritative DNS and DNS-over-HTTPS, smartradius, smartvpn, remote ingress tunnels, a TypedRequest API, and the Ops dashboard.
|
||||
|
||||
Use it when you want one place to define routes, manage domains and certificates, protect internal services, automate changes over an API, and operate the whole stack from a browser.
|
||||
`dcrouter` is the serve.zone datacenter gateway runtime: a TypeScript control plane that brings HTTP/HTTPS/TCP routing, email ingress, authoritative DNS, RADIUS, VPN access control, remote ingress tunnels, certificate operations, metrics, and an Ops dashboard into one process.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Why It Works
|
||||
## Why It Exists
|
||||
|
||||
- 🌐 One runtime for HTTP/HTTPS/TCP, SMTP, authoritative DNS + DoH, RADIUS, VPN, and remote ingress.
|
||||
- 🧠 Constructor config becomes system-managed routes, while API-created routes stay editable and clearly separated.
|
||||
- 🔐 Certificates, DNS providers, domains, records, API tokens, access profiles, and protected routes live in one management plane.
|
||||
- 🖥️ The OpsServer UI and TypedRequest API are first-class parts of the package, not an afterthought.
|
||||
- ⚡ Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default unless you opt out.
|
||||
Modern infrastructure often has too many tiny edge tools: a proxy here, a DNS daemon there, a separate cert worker, another dashboard, and a tunnel process bolted on later. `dcrouter` is designed as a cohesive gateway layer for operators who want one audited place to define public routes, domains, edge tunnels, access policy, and operational state.
|
||||
|
||||
## What You Get
|
||||
Highlights:
|
||||
|
||||
| Area | What dcrouter does |
|
||||
- 🌐 SmartProxy-backed HTTP, HTTPS, TCP, TLS/SNI, and optional HTTP/3 route handling
|
||||
- 📬 SmartMTA-backed SMTP ingress and email-domain operations
|
||||
- 🧭 SmartDNS-backed authoritative DNS plus generated DNS-over-HTTPS routes
|
||||
- 🔐 ACME, certificate state, API tokens, users, source profiles, target profiles, and security policies
|
||||
- 🛡️ RADIUS, VLAN assignment, VPN-protected routes, and remote ingress firewall snapshots
|
||||
- 🖥️ Browser Ops dashboard and TypedRequest API served by the built-in OpsServer
|
||||
|
||||
## Runtime Areas
|
||||
|
||||
| Area | What dcrouter manages |
|
||||
| --- | --- |
|
||||
| HTTP / HTTPS / TCP | SmartProxy-based reverse proxying, TLS termination or passthrough, path/domain/port matching, TCP/SNI forwarding |
|
||||
| Email | SMTP ingress and delivery via `UnifiedEmailServer`, route-based mail actions, email-domain management, queue and resend operations |
|
||||
| DNS | Authoritative zones, nameserver bootstrap records, DNS-over-HTTPS routes on `/dns-query` and `/resolve`, provider-backed domain management |
|
||||
| Access and Edge | VPN-gated routes, RADIUS auth/accounting, remote ingress edge registrations and tunnel hub support |
|
||||
| Operations | Browser dashboard, TypedRequest API, route management, tokens, certificates, logs, metrics, and health views |
|
||||
| Proxying | SmartProxy routes for HTTP, HTTPS, TCP, SNI, TLS termination, passthrough, and backend forwarding |
|
||||
| Route ownership | Constructor routes, generated email/DNS routes, and API-created routes with explicit origins |
|
||||
| DNS | Authoritative scopes, generated NS records, static DNS records, provider-backed domains, and DoH endpoints |
|
||||
| Email | UnifiedEmailServer startup, email-domain management, route-backed delivery actions, received mail operations |
|
||||
| Certificates | ACME config, stored certificate metadata, provisioning backoff, and certificate status reporting |
|
||||
| Edge access | Remote ingress hub, edge registrations, derived edge ports, pushed firewall rules, VPN-only route access |
|
||||
| Network auth | RADIUS clients, MAC Authentication Bypass, VLAN mapping, and accounting sessions |
|
||||
| Operations | Dashboard views, TypedRequest handlers, metrics, logs, health, API tokens, users, and configuration views |
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter
|
||||
@@ -36,7 +40,7 @@ pnpm add @serve.zone/dcrouter
|
||||
|
||||
## Quick Start
|
||||
|
||||
This example stays on unprivileged ports so you can run it locally without root.
|
||||
This starts the gateway on unprivileged ports and stores data under the default `~/.serve.zone/dcrouter` base directory.
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
@@ -69,86 +73,89 @@ await router.start();
|
||||
After startup:
|
||||
|
||||
- open the dashboard at `http://localhost:3000`
|
||||
- log in with the current built-in credentials `admin` / `admin`
|
||||
- complete the first-admin bootstrap flow if no persisted admin account exists yet
|
||||
- send proxied traffic to `http://localhost:18080`
|
||||
- stop gracefully with `await router.stop()`
|
||||
|
||||
## Route Ownership Model
|
||||
## Initial Admin Bootstrap
|
||||
|
||||
dcrouter keeps route ownership explicit so automation does not accidentally stomp on system-generated traffic.
|
||||
When DB-backed persistence is enabled and no persisted admin exists, dcrouter does not auto-create an admin account. The Ops dashboard exposes a non-cancelable first-admin bootstrap flow that must be completed explicitly.
|
||||
|
||||
| Route origin | Where it comes from | What you can do |
|
||||
| --- | --- | --- |
|
||||
| `config` | Constructor `smartProxyConfig.routes` and related seed config | View and toggle |
|
||||
| `email` | Email listener and mail-routing derived routes | View and toggle |
|
||||
| `dns` | Generated DoH and DNS-related routes | View and toggle |
|
||||
| `api` | Created through the Ops UI or API client | Full CRUD |
|
||||
Bootstrap behavior:
|
||||
|
||||
Important details:
|
||||
- `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required.
|
||||
- The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
|
||||
- `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
|
||||
- Optional `idp.global` authentication can be enabled for that local account. The hosted `https://idp.global` endpoint is used by default, `adminAuth.idpGlobalUrl` or `DCROUTER_IDP_GLOBAL_URL` only override it, and the local dcrouter role remains authoritative.
|
||||
- After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.
|
||||
|
||||
- system routes are persisted with stable `systemKey` values
|
||||
- DNS-over-HTTPS routes are persisted and then hydrated with live socket handlers at runtime
|
||||
- editing and deletion are reserved for `api` routes; system routes are toggle-only by design
|
||||
## Configuration Model
|
||||
|
||||
## Configuration Cheat Sheet
|
||||
|
||||
The main entrypoint is `IDcRouterOptions`.
|
||||
`DcRouter` is configured with `IDcRouterOptions` from `@serve.zone/dcrouter`.
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `smartProxyConfig` | Main HTTP/HTTPS and TCP/SNI routing config |
|
||||
| `emailConfig` | Email hostname, ports, domains, and mail routing rules |
|
||||
| `emailPortConfig` | External-to-internal email port remapping and received-email storage path |
|
||||
| `tls` | ACME contact and static certificate paths |
|
||||
| `dnsNsDomains` | Nameserver hostnames used for NS bootstrap and DoH route generation |
|
||||
| `dnsScopes` | Domains served authoritatively by the embedded DNS server |
|
||||
| `dnsRecords` | Static constructor-defined DNS records |
|
||||
| `publicIp` / `proxyIps` | How A records are exposed for nameserver and service records |
|
||||
| `dbConfig` | Embedded LocalSmartDb or external MongoDB-backed persistence |
|
||||
| `radiusConfig` | RADIUS auth, VLAN assignment, and accounting |
|
||||
| `remoteIngressConfig` | Remote ingress hub and edge tunnel setup |
|
||||
| `vpnConfig` | VPN server and client definitions for protected route access |
|
||||
| `http3` | Global HTTP/3 behavior for qualifying HTTPS routes |
|
||||
| `opsServerPort` | Ops dashboard and TypedRequest API port |
|
||||
| `baseDir` | Root directory for dcrouter runtime data. Defaults to `~/.serve.zone/dcrouter`. |
|
||||
| `smartProxyConfig` | Main SmartProxy route configuration for HTTP/HTTPS/TCP/SNI traffic. |
|
||||
| `emailConfig` | UnifiedEmailServer configuration: hostname, ports, domains, and mail routes. |
|
||||
| `emailPortConfig` | External-to-internal email port mapping and received-email storage path. |
|
||||
| `tls` | Legacy/static TLS and ACME contact settings used to seed certificate config. |
|
||||
| `dnsNsDomains` | Nameserver hostnames used for generated NS records and DoH routes. |
|
||||
| `dnsScopes` | Authoritative domains served by the embedded DNS server. |
|
||||
| `dnsRecords` | Constructor-defined DNS records. |
|
||||
| `publicIp` / `proxyIps` | IPs used for generated A records and proxy-aware DNS exposure. |
|
||||
| `dbConfig` | Smartdata persistence via embedded LocalSmartDb or external MongoDB. |
|
||||
| `radiusConfig` | RADIUS authentication, accounting, and VLAN assignment. |
|
||||
| `remoteIngressConfig` | Remote ingress hub configuration for edge tunnel nodes. |
|
||||
| `vpnConfig` | VPN server/client definitions and VPN-only routing behavior. |
|
||||
| `http3` | HTTP/3 augmentation settings for qualifying HTTPS routes. |
|
||||
| `opsServerPort` | Port for the Ops dashboard and `/typedrequest` API. Defaults to `3000`. |
|
||||
|
||||
## Important Behavior
|
||||
Important runtime behavior:
|
||||
|
||||
- `dbConfig.enabled` defaults to `true`. If you do not provide `mongoDbUrl`, dcrouter starts an embedded local database automatically.
|
||||
- If you disable the DB, constructor-driven traffic can still run, but DB-backed features such as persistent routes, tokens, ACME config, and managed domains do not start.
|
||||
- Qualifying HTTPS forward routes on port `443` get HTTP/3 by default unless `http3.enabled === false` or the route opts out.
|
||||
- DNS-over-HTTPS endpoints are generated on the first entry of `dnsNsDomains` at `/dns-query` and `/resolve`.
|
||||
- Email listener ports are internally remapped by default, so common external ports such as `25`, `587`, and `465` end up on internal ports like `10025`, `10587`, and `10465`.
|
||||
- Provider-backed domains can be managed in the Ops plane without being served by the embedded authoritative DNS server.
|
||||
- `dbConfig.enabled` defaults to enabled. Without `mongoDbUrl`, dcrouter uses embedded LocalSmartDb.
|
||||
- If the DB is disabled, constructor-defined proxy traffic can still run, but persistent API routes, tokens, managed domains, and stored certificate state are unavailable.
|
||||
- Qualifying HTTPS forward routes on port `443` are HTTP/3-augmented unless `http3.enabled === false` or the route opts out.
|
||||
- DNS-over-HTTPS routes are generated on the first `dnsNsDomains` entry at `/dns-query` and `/resolve`.
|
||||
- Email listener ports can be remapped internally, for example public `25`, `587`, and `465` to unprivileged internal ports.
|
||||
|
||||
## Bigger Example
|
||||
## Route Ownership
|
||||
|
||||
dcrouter keeps generated and operator-created routes separate so automation can reconcile safely.
|
||||
|
||||
| Origin | Source | Mutability |
|
||||
| --- | --- | --- |
|
||||
| `config` | Constructor `smartProxyConfig.routes` and seed data | Toggle only |
|
||||
| `email` | Email listener and email-domain generated routes | Toggle only |
|
||||
| `dns` | Generated DNS-over-HTTPS and DNS-related routes | Toggle only |
|
||||
| `api` | Ops UI or API client | Full CRUD |
|
||||
|
||||
System routes are persisted with stable `systemKey` values. API-created routes are the editable route layer intended for operators and automation.
|
||||
|
||||
## Production-Flavored Example
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
baseDir: '/var/lib/dcrouter',
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-app',
|
||||
match: {
|
||||
domains: ['app.example.com'],
|
||||
ports: [443],
|
||||
},
|
||||
match: { domains: ['app.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
targets: [{ host: '10.10.0.21', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'internal-admin',
|
||||
match: {
|
||||
domains: ['internal.example.com'],
|
||||
ports: [443],
|
||||
},
|
||||
match: { domains: ['admin.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 9090 }],
|
||||
targets: [{ host: '10.10.0.30', port: 9000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
vpnOnly: true,
|
||||
@@ -158,15 +165,10 @@ const router = new DcRouter({
|
||||
emailConfig: {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587, 465],
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'internal-dns',
|
||||
},
|
||||
],
|
||||
domains: [{ domain: 'example.com', dnsMode: 'internal-dns' }],
|
||||
routes: [
|
||||
{
|
||||
name: 'inbound-mail',
|
||||
name: 'inbound-example',
|
||||
match: { recipients: '*@example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
@@ -178,18 +180,15 @@ const router = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
publicIp: '203.0.113.10',
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.example.com',
|
||||
clients: [
|
||||
{
|
||||
clientId: 'ops-laptop',
|
||||
description: 'Operations laptop',
|
||||
},
|
||||
],
|
||||
},
|
||||
dbConfig: {
|
||||
enabled: true,
|
||||
clients: [{ clientId: 'ops-laptop', description: 'Operations laptop' }],
|
||||
},
|
||||
opsServerPort: 3000,
|
||||
});
|
||||
@@ -197,15 +196,22 @@ const router = new DcRouter({
|
||||
await router.start();
|
||||
```
|
||||
|
||||
## Automation
|
||||
## VPN Target Profiles
|
||||
|
||||
dcrouter gives you three good integration layers:
|
||||
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.
|
||||
|
||||
- the browser dashboard served by `OpsServer`
|
||||
- raw TypedRequest contracts via `@serve.zone/dcrouter/interfaces`
|
||||
- a higher-level OO API client via `@serve.zone/dcrouter/apiclient` or `@serve.zone/dcrouter-apiclient`
|
||||
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.
|
||||
|
||||
### OO API Client Example
|
||||
```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.
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
@@ -218,26 +224,22 @@ const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
|
||||
await client.login('admin', 'admin');
|
||||
await client.login('admin@example.com', 'strong-password');
|
||||
|
||||
const { routes } = await client.routes.list();
|
||||
|
||||
await client.routes.build()
|
||||
const route = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8081 }] })
|
||||
.save();
|
||||
|
||||
if (routes[0] && routes[0].origin !== 'api') {
|
||||
await routes[0].toggle(false);
|
||||
}
|
||||
await route.toggle(true);
|
||||
```
|
||||
|
||||
See `./ts_apiclient/readme.md` for the dedicated client package and `./ts_interfaces/readme.md` for the raw contracts.
|
||||
Use `@serve.zone/dcrouter/interfaces` or `@serve.zone/dcrouter-interfaces` when you want the raw TypedRequest contracts instead of resource managers.
|
||||
|
||||
## OCI / Container Bootstrap
|
||||
|
||||
The package also includes an environment-driven bootstrap used by `runCli()` when `DCROUTER_MODE=OCI_CONTAINER`.
|
||||
`runCli()` supports an environment-driven container mode when `DCROUTER_MODE=OCI_CONTAINER`.
|
||||
|
||||
```typescript
|
||||
import { runCli } from '@serve.zone/dcrouter';
|
||||
@@ -245,49 +247,52 @@ import { runCli } from '@serve.zone/dcrouter';
|
||||
await runCli();
|
||||
```
|
||||
|
||||
Useful environment variables include:
|
||||
Supported environment overrides include:
|
||||
|
||||
- `DCROUTER_CONFIG_PATH`
|
||||
- `DCROUTER_BASE_DIR`
|
||||
- `DCROUTER_TLS_EMAIL`
|
||||
- `DCROUTER_TLS_DOMAIN`
|
||||
- `DCROUTER_PUBLIC_IP`
|
||||
- `DCROUTER_PROXY_IPS`
|
||||
- `DCROUTER_DNS_NS_DOMAINS`
|
||||
- `DCROUTER_DNS_SCOPES`
|
||||
- `DCROUTER_EMAIL_HOSTNAME`
|
||||
- `DCROUTER_EMAIL_PORTS`
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `DCROUTER_CONFIG_PATH` | JSON file loaded as the base `IDcRouterOptions` object. |
|
||||
| `DCROUTER_BASE_DIR` | Runtime data root. |
|
||||
| `DCROUTER_TLS_EMAIL` / `DCROUTER_TLS_DOMAIN` | TLS/ACME seed settings. |
|
||||
| `DCROUTER_PUBLIC_IP` / `DCROUTER_PROXY_IPS` | Public/proxy IP exposure settings. |
|
||||
| `DCROUTER_DNS_NS_DOMAINS` / `DCROUTER_DNS_SCOPES` | DNS nameserver and authoritative scope settings. |
|
||||
| `DCROUTER_EMAIL_HOSTNAME` / `DCROUTER_EMAIL_PORTS` | Email server seed settings. |
|
||||
| `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. |
|
||||
|
||||
## Published Modules
|
||||
|
||||
This repository ships several module boundaries from one codebase.
|
||||
This repository intentionally publishes multiple module boundaries from one codebase.
|
||||
|
||||
| Module | Purpose | Docs |
|
||||
| --- | --- | --- |
|
||||
| `@serve.zone/dcrouter` | Main runtime and orchestrator | `./readme.md` |
|
||||
| `@serve.zone/dcrouter/interfaces` | Shared request and data contracts as a subpath export | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter/apiclient` | OO API client as a subpath export | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-interfaces` | Standalone interfaces package | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter/interfaces` | Shared contracts as a subpath export | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter/apiclient` | API client as a subpath export | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-interfaces` | Standalone contracts package | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter-apiclient` | Standalone OO API client package | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-migrations` | Standalone migration runner package | `./ts_migrations/readme.md` |
|
||||
| `@serve.zone/dcrouter-web` | Standalone web dashboard package boundary | `./ts_web/readme.md` |
|
||||
| `@serve.zone/dcrouter-web` | Dashboard frontend module boundary | `./ts_web/readme.md` |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm test
|
||||
pnpm run watch
|
||||
```
|
||||
|
||||
Target a single test while working on one area:
|
||||
Useful source entry points:
|
||||
|
||||
```bash
|
||||
tstest test/test.apiclient.ts --verbose
|
||||
```
|
||||
- `ts/index.ts` exports `DcRouter`, `runCli()`, and public module surfaces.
|
||||
- `ts/classes.dcrouter.ts` owns service startup, dependency ordering, and `IDcRouterOptions`.
|
||||
- `ts/opsserver/classes.opsserver.ts` wires the dashboard server and TypedRequest handlers.
|
||||
- `ts/remoteingress/` integrates `@serve.zone/remoteingress` with stored edge registrations.
|
||||
- `ts_migrations/index.ts` contains all DB schema migration steps.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { OpsServer } from '../ts/opsserver/index.js';
|
||||
import { DcRouterDb } from '../ts/db/index.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const testPort = 3110;
|
||||
const baseUrl = `http://localhost:${testPort}/typedrequest`;
|
||||
const bootstrapPassword = 'temporary-bootstrap-password';
|
||||
const persistedPassword = 'persisted-admin-password';
|
||||
|
||||
let previousAdminPassword: string | undefined;
|
||||
let opsServer: OpsServer;
|
||||
let testDb: DcRouterDb;
|
||||
let storagePath: string;
|
||||
let dbName: string;
|
||||
let bootstrapIdentity: interfaces.data.IIdentity;
|
||||
let persistedIdentity: interfaces.data.IIdentity;
|
||||
let createdUserId: string;
|
||||
|
||||
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||
baseUrl,
|
||||
'getAdminBootstrapStatus',
|
||||
);
|
||||
|
||||
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
baseUrl,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
);
|
||||
|
||||
const createFakeDcRouter = (portArg: number, dcRouterDbArg?: DcRouterDb) => ({
|
||||
options: {
|
||||
opsServerPort: portArg,
|
||||
dbConfig: { enabled: true },
|
||||
adminAuth: {
|
||||
idpClient: {
|
||||
loginWithEmailAndPassword: async () => ({
|
||||
jwt: 'idp-jwt',
|
||||
refreshToken: 'idp-refresh-token',
|
||||
user: {
|
||||
id: 'idp-user-1',
|
||||
data: {
|
||||
name: 'Wrong IdP User',
|
||||
username: 'wrong@example.com',
|
||||
email: 'wrong@example.com',
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stop: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
typedrouter: new plugins.typedrequest.TypedRouter(),
|
||||
dcRouterDb: dcRouterDbArg,
|
||||
});
|
||||
|
||||
const restartOpsServer = async () => {
|
||||
await opsServer.stop();
|
||||
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
|
||||
await opsServer.start();
|
||||
};
|
||||
|
||||
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
|
||||
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
|
||||
|
||||
storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
testDb = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName,
|
||||
});
|
||||
await testDb.start();
|
||||
await testDb.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
|
||||
await opsServer.start();
|
||||
});
|
||||
|
||||
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
|
||||
const status = await createStatusRequest().fire({});
|
||||
|
||||
expect(status.dbEnabled).toEqual(true);
|
||||
expect(status.dbReady).toEqual(true);
|
||||
expect(status.hasPersistentAdmin).toEqual(false);
|
||||
expect(status.needsBootstrap).toEqual(true);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(true);
|
||||
expect(status.idpGlobalConfigured).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
|
||||
const response = await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected bootstrap login identity');
|
||||
}
|
||||
bootstrapIdentity = response.identity;
|
||||
expect(bootstrapIdentity.role).toEqual('admin');
|
||||
});
|
||||
|
||||
tap.test('creates the initial persisted admin explicitly', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||
baseUrl,
|
||||
'createInitialAdminUser',
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
identity: bootstrapIdentity,
|
||||
email: 'Admin@Example.com',
|
||||
name: 'Persisted Admin',
|
||||
password: persistedPassword,
|
||||
enableIdpGlobalAuth: true,
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
expect(response.user?.role).toEqual('admin');
|
||||
expect(response.user?.authSources).toContain('local');
|
||||
expect(response.user?.authSources).toContain('idp.global');
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected persisted admin identity');
|
||||
}
|
||||
persistedIdentity = response.identity;
|
||||
});
|
||||
|
||||
tap.test('disables bootstrap mode after persisted admin exists', async () => {
|
||||
const status = await createStatusRequest().fire({});
|
||||
|
||||
expect(status.hasPersistentAdmin).toEqual(true);
|
||||
expect(status.needsBootstrap).toEqual(false);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
baseUrl,
|
||||
'verifyIdentity',
|
||||
);
|
||||
const response = await request.fire({ identity: bootstrapIdentity });
|
||||
|
||||
expect(response.valid).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('authenticates the persisted admin locally by normalized email', async () => {
|
||||
const response = await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: persistedPassword,
|
||||
authSource: 'local',
|
||||
});
|
||||
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected persisted admin login identity');
|
||||
}
|
||||
expect(response.identity.userId).toEqual(persistedIdentity.userId);
|
||||
});
|
||||
|
||||
tap.test('persists users across OpsServer restart', async () => {
|
||||
const oldPersistedIdentity = persistedIdentity;
|
||||
await restartOpsServer();
|
||||
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
baseUrl,
|
||||
'verifyIdentity',
|
||||
);
|
||||
const verifyResponse = await verifyRequest.fire({ identity: oldPersistedIdentity });
|
||||
expect(verifyResponse.valid).toEqual(false);
|
||||
|
||||
const loginResponse = await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: persistedPassword,
|
||||
authSource: 'local',
|
||||
});
|
||||
|
||||
if (!loginResponse.identity) {
|
||||
throw new Error('Expected persisted admin login identity after restart');
|
||||
}
|
||||
expect(loginResponse.identity.userId).toEqual(oldPersistedIdentity.userId);
|
||||
persistedIdentity = loginResponse.identity;
|
||||
});
|
||||
|
||||
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: 'idp-password',
|
||||
authSource: 'idp.global',
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('creates a persisted non-admin user explicitly', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_CreateUser>(baseUrl, 'createUser');
|
||||
const response = await request.fire({
|
||||
identity: persistedIdentity,
|
||||
email: 'operator@example.com',
|
||||
name: 'Operator User',
|
||||
role: 'user',
|
||||
password: 'operator-password',
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
expect(response.user?.role).toEqual('user');
|
||||
expect(response.user?.email).toEqual('operator@example.com');
|
||||
if (!response.user?.id) {
|
||||
throw new Error('Expected created user id');
|
||||
}
|
||||
createdUserId = response.user.id;
|
||||
});
|
||||
|
||||
tap.test('rejects deleting the current persisted admin user', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
|
||||
const response = await request.fire({
|
||||
identity: persistedIdentity,
|
||||
id: persistedIdentity.userId,
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('deletes a persisted non-current user', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
|
||||
const response = await request.fire({
|
||||
identity: persistedIdentity,
|
||||
id: createdUserId,
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('lists persisted users without password material', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
|
||||
const response = await request.fire({ identity: persistedIdentity });
|
||||
|
||||
expect(response.users.length).toEqual(1);
|
||||
expect(response.users[0].email).toEqual('Admin@Example.com');
|
||||
expect((response.users[0] as any).password).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('rejects temporary bootstrap admin when persisted-user database is unavailable', async () => {
|
||||
await testDb.stop();
|
||||
|
||||
const status = await createStatusRequest().fire({});
|
||||
expect(status.dbEnabled).toEqual(true);
|
||||
expect(status.dbReady).toEqual(false);
|
||||
expect(status.needsBootstrap).toEqual(false);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
|
||||
await opsServer.stop();
|
||||
await testDb.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
|
||||
if (previousAdminPassword === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not offer bootstrap while configured database is unavailable', async () => {
|
||||
const unavailablePort = 3111;
|
||||
const unavailableBaseUrl = `http://localhost:${unavailablePort}/typedrequest`;
|
||||
const previousUnavailableAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = 'unavailable-bootstrap-password';
|
||||
DcRouterDb.resetInstance();
|
||||
|
||||
const unavailableOpsServer = new OpsServer(createFakeDcRouter(unavailablePort) as any);
|
||||
try {
|
||||
await unavailableOpsServer.start();
|
||||
const status = await new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||
unavailableBaseUrl,
|
||||
'getAdminBootstrapStatus',
|
||||
).fire({});
|
||||
|
||||
expect(status.dbEnabled).toEqual(true);
|
||||
expect(status.dbReady).toEqual(false);
|
||||
expect(status.needsBootstrap).toEqual(false);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
await new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
unavailableBaseUrl,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
).fire({
|
||||
username: 'admin',
|
||||
password: 'unavailable-bootstrap-password',
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
} finally {
|
||||
await unavailableOpsServer.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
if (previousUnavailableAdminPassword === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = previousUnavailableAdminPassword;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,75 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { ApiTokenManager } from '../ts/config/classes.api-token-manager.js';
|
||||
import { DcRouterDb } from '../ts/db/index.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('ApiTokenManager seeds and rotates an env admin API token', async () => {
|
||||
const previousToken = process.env.DCROUTER_ADMIN_API_TOKEN;
|
||||
const previousName = process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
|
||||
const testDb = await createTestDb();
|
||||
|
||||
try {
|
||||
const rawToken1 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||
const rawToken2 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken1;
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = 'Onebox Managed Admin';
|
||||
|
||||
const manager = new ApiTokenManager();
|
||||
await manager.initialize();
|
||||
|
||||
const token1 = await manager.validateToken(rawToken1);
|
||||
expect(token1?.id).toEqual('env-admin-token');
|
||||
expect(token1?.name).toEqual('Onebox Managed Admin');
|
||||
expect(token1?.policy?.role).toEqual('admin');
|
||||
expect(manager.hasScope(token1!, 'tokens:manage')).toEqual(true);
|
||||
|
||||
const listedToken = manager.listTokens().find((token) => token.id === 'env-admin-token') as any;
|
||||
expect(listedToken.tokenHash).toBeUndefined();
|
||||
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken2;
|
||||
const rotatedManager = new ApiTokenManager();
|
||||
await rotatedManager.initialize();
|
||||
|
||||
expect(await rotatedManager.validateToken(rawToken1)).toBeNull();
|
||||
const token2 = await rotatedManager.validateToken(rawToken2);
|
||||
expect(token2?.id).toEqual('env-admin-token');
|
||||
expect(token2?.policy?.role).toEqual('admin');
|
||||
} finally {
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_API_TOKEN;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN = previousToken;
|
||||
}
|
||||
if (previousName === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = previousName;
|
||||
}
|
||||
await testDb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,201 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||
import { AcmeCertDoc, DcRouterDb } from '../ts/db/index.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
type TScope = interfaces.data.TApiTokenScope;
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-cert-api-token-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const makeApiTokenManager = (scopes: TScope[]) => {
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'certificate-test-token',
|
||||
scopes,
|
||||
createdBy: 'token-user',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
} as interfaces.data.IStoredApiToken;
|
||||
|
||||
return {
|
||||
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
|
||||
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope),
|
||||
};
|
||||
};
|
||||
|
||||
const setupHandler = (scopes: TScope[], options?: {
|
||||
routes?: any[];
|
||||
certProvisionScheduler?: any;
|
||||
certProvisionFunction?: (...args: any[]) => any;
|
||||
}) => {
|
||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
validateIdentity: async () => null,
|
||||
adminIdentityGuard: {
|
||||
exec: async () => false,
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
apiTokenManager: makeApiTokenManager(scopes),
|
||||
certificateStatusMap: new Map(),
|
||||
smartProxy: {
|
||||
settings: options?.certProvisionFunction ? {
|
||||
certProvisionFunction: options.certProvisionFunction,
|
||||
} : {},
|
||||
routeManager: { getRoutes: () => options?.routes ?? [] },
|
||||
getCertificateStatus: async () => null,
|
||||
},
|
||||
certProvisionScheduler: options?.certProvisionScheduler ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
new CertificateHandler(opsServerRef);
|
||||
return { typedrouter, opsServerRef };
|
||||
};
|
||||
|
||||
const fireTypedRequest = async (
|
||||
router: plugins.typedrequest.TypedRouter,
|
||||
method: string,
|
||||
request: Record<string, any>,
|
||||
) => {
|
||||
return await router.routeAndAddResponse({
|
||||
method,
|
||||
request,
|
||||
response: {},
|
||||
correlation: {
|
||||
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
phase: 'request',
|
||||
},
|
||||
} as any, { localRequest: true, skipHooks: true }) as any;
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
tap.test('CertificateHandler allows API-token export with certificates:read', async () => {
|
||||
await testDbPromise;
|
||||
|
||||
const certDoc = new AcmeCertDoc();
|
||||
certDoc.id = 'cert-1';
|
||||
certDoc.domainName = 'example.com';
|
||||
certDoc.created = 1;
|
||||
certDoc.validUntil = 2;
|
||||
certDoc.privateKey = '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----';
|
||||
certDoc.publicKey = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----';
|
||||
certDoc.csr = '';
|
||||
await certDoc.save();
|
||||
|
||||
const { typedrouter } = setupHandler(['certificates:read']);
|
||||
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
|
||||
apiToken: 'valid-token',
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.success).toEqual(true);
|
||||
expect(result.response.cert.domainName).toEqual('example.com');
|
||||
expect(result.response.cert.privateKey).toContain('BEGIN PRIVATE KEY');
|
||||
expect(result.response.cert.publicKey).toContain('BEGIN CERTIFICATE');
|
||||
});
|
||||
|
||||
tap.test('CertificateHandler rejects API-token export without certificates:read', async () => {
|
||||
const { typedrouter } = setupHandler(['certificates:write']);
|
||||
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
|
||||
apiToken: 'valid-token',
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
tap.test('CertificateHandler allows API-token import with certificates:write', async () => {
|
||||
await testDbPromise;
|
||||
|
||||
const { typedrouter, opsServerRef } = setupHandler(['certificates:write']);
|
||||
const result = await fireTypedRequest(typedrouter, 'importCertificate', {
|
||||
apiToken: 'valid-token',
|
||||
cert: {
|
||||
id: 'cert-2',
|
||||
domainName: 'imported.example.com',
|
||||
created: 3,
|
||||
validUntil: 4,
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||
csr: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.success).toEqual(true);
|
||||
expect((await AcmeCertDoc.findByDomain('imported.example.com'))?.id).toEqual('cert-2');
|
||||
expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid');
|
||||
});
|
||||
|
||||
tap.test('CertificateHandler reports active certificate backoff as failed with root cause', async () => {
|
||||
await testDbPromise;
|
||||
|
||||
const lastError = 'DNS-01 failed for stack.gallery: DnsManager: no managed domain found for _acme-challenge.stack.gallery.';
|
||||
const retryAfter = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const { typedrouter } = setupHandler(['certificates:read'], {
|
||||
certProvisionFunction: async () => 'http01',
|
||||
certProvisionScheduler: {
|
||||
getBackoffInfo: async (domain: string) => domain === 'stack.gallery'
|
||||
? { failures: 11, retryAfter, lastError }
|
||||
: null,
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
name: 'stack-gallery',
|
||||
match: { domains: ['stack.gallery'] },
|
||||
action: {
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'getCertificateOverview', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.summary.failed).toEqual(1);
|
||||
expect(result.response.certificates[0].status).toEqual('failed');
|
||||
expect(result.response.certificates[0].error).toEqual(lastError);
|
||||
expect(result.response.certificates[0].backoffInfo.failures).toEqual(11);
|
||||
});
|
||||
|
||||
tap.test('cleanup test db', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await testDb.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const TEST_PORT = 3201;
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||
const TEST_ADMIN_PASSWORD = 'test-admin-password';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
@@ -56,6 +57,7 @@ const queueItems = [
|
||||
];
|
||||
|
||||
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
|
||||
testDcRouter = new DcRouter({
|
||||
opsServerPort: TEST_PORT,
|
||||
dbConfig: { enabled: false },
|
||||
@@ -98,7 +100,7 @@ tap.test('should login as admin for email API tests', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
password: TEST_ADMIN_PASSWORD,
|
||||
});
|
||||
|
||||
const responseIdentity = response.identity;
|
||||
|
||||
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3102,
|
||||
@@ -25,7 +27,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
password: testAdminPassword
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
|
||||
@@ -22,14 +22,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 +49,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,
|
||||
@@ -239,4 +246,84 @@ 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,
|
||||
routeManager: { getRoutes: () => [] },
|
||||
},
|
||||
securityPolicyManager: {
|
||||
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
|
||||
listIpIntelligence: async () => [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
await manager.getNetworkStats();
|
||||
|
||||
expect(queuedIps).toHaveLength(1);
|
||||
expect(queuedIps[0]).toContain('8.8.8.8');
|
||||
expect(queuedIps[0]).toContain('1.1.1.1');
|
||||
});
|
||||
|
||||
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map(),
|
||||
throughputByRoute: new Map(),
|
||||
domainRequestsByIP: new Map(),
|
||||
connectionsByIP: new Map([
|
||||
['8.8.8.8', 4],
|
||||
['8.8.4.4', 3],
|
||||
['1.1.1.1', 5],
|
||||
]),
|
||||
throughputByIP: new Map([
|
||||
['8.8.8.8', { in: 500, out: 250 }],
|
||||
['8.8.4.4', { in: 700, out: 350 }],
|
||||
['1.1.1.1', { in: 2000, out: 1000 }],
|
||||
]),
|
||||
});
|
||||
|
||||
const manager = new MetricsManager({
|
||||
smartProxy: {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: { getRoutes: () => [] },
|
||||
},
|
||||
securityPolicyManager: {
|
||||
queueObservedIps: () => undefined,
|
||||
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
|
||||
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
|
||||
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
|
||||
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
|
||||
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const stats = await manager.getNetworkStats();
|
||||
|
||||
expect(stats.topASNs).toHaveLength(2);
|
||||
expect(stats.topASNs[0].asn).toEqual(15169);
|
||||
expect(stats.topASNs[0].organization).toEqual('Google LLC');
|
||||
expect(stats.topASNs[0].activeConnections).toEqual(7);
|
||||
expect(stats.topASNs[0].ipCount).toEqual(2);
|
||||
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
|
||||
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
|
||||
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
|
||||
expect(stats.topASNs[1].asn).toEqual(13335);
|
||||
expect(stats.topASNs[1].activeConnections).toEqual(5);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { createMigrationRunner } from '../ts_migrations/index.js';
|
||||
|
||||
function setPath(target: Record<string, any>, path: string, value: unknown): void {
|
||||
const parts = path.split('.');
|
||||
let cursor = target;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
cursor[part] = cursor[part] || {};
|
||||
cursor = cursor[part];
|
||||
}
|
||||
cursor[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(set)) {
|
||||
setPath(document, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeDb(currentVersion: string) {
|
||||
const ledgerDocument = {
|
||||
nameId: 'smartmigration:smartmigration',
|
||||
data: {
|
||||
currentVersion,
|
||||
steps: {},
|
||||
lock: { holder: null, acquiredAt: null, expiresAt: null },
|
||||
checkpoints: {},
|
||||
},
|
||||
};
|
||||
|
||||
const emptyCollection = {
|
||||
find: () => ({
|
||||
async *[Symbol.asyncIterator]() {},
|
||||
}),
|
||||
updateMany: async () => ({ modifiedCount: 0 }),
|
||||
};
|
||||
|
||||
const ledgerCollection = {
|
||||
createIndex: async () => undefined,
|
||||
findOne: async () => structuredClone(ledgerDocument),
|
||||
findOneAndUpdate: async (_query: unknown, update: any) => {
|
||||
applySet(ledgerDocument, update.$set || {});
|
||||
return structuredClone(ledgerDocument);
|
||||
},
|
||||
updateOne: async (_query: unknown, update: any) => {
|
||||
applySet(ledgerDocument, update.$set || {});
|
||||
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
mongoDb: {
|
||||
collection: (name: string) =>
|
||||
name === 'SmartdataEasyStore' ? ledgerCollection : emptyCollection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('migration runner bridges old package-version targets without real schema steps', async () => {
|
||||
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.31.0');
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.currentVersionBefore).toEqual('13.16.0');
|
||||
expect(result.currentVersionAfter).toEqual('13.31.0');
|
||||
expect(result.stepsApplied).toHaveLength(3);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3101,
|
||||
@@ -25,7 +27,7 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
password: testAdminPassword,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
|
||||
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3103,
|
||||
@@ -25,7 +27,7 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
password: testAdminPassword
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { DcRouterDb, IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../ts/db/index.js';
|
||||
import { SecurityPolicyManager } from '../ts/security/index.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearTestState = async () => {
|
||||
for (const rule of await SecurityBlockRuleDoc.findAll()) {
|
||||
await rule.delete();
|
||||
}
|
||||
for (const record of await IpIntelligenceDoc.findAll()) {
|
||||
await record.delete();
|
||||
}
|
||||
for (const event of await SecurityPolicyAuditDoc.findRecent(1000)) {
|
||||
await event.delete();
|
||||
}
|
||||
};
|
||||
|
||||
const createIntelligenceResult = (asn: number) => ({
|
||||
asn,
|
||||
asnOrg: `ASN ${asn}`,
|
||||
registrantOrg: null,
|
||||
registrantCountry: null,
|
||||
networkRange: null,
|
||||
networkCidrs: null,
|
||||
abuseContact: null,
|
||||
country: null,
|
||||
countryCode: 'US',
|
||||
city: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadius: null,
|
||||
timezone: null,
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
await manager.createBlockRule({
|
||||
type: 'cidr',
|
||||
value: '203.0.113.0 - 203.0.113.255',
|
||||
reason: 'test range',
|
||||
});
|
||||
|
||||
const policy = await manager.compilePolicy();
|
||||
expect(policy.blockedCidrs).toEqual(['203.0.113.0/24']);
|
||||
|
||||
const firewall = await manager.compileRemoteIngressFirewall();
|
||||
expect(firewall.blockedIps).toEqual(['203.0.113.0/24']);
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager compiles intelligence network ranges for ASN rules', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
const intelligenceDoc = new IpIntelligenceDoc();
|
||||
intelligenceDoc.ipAddress = '198.51.100.23';
|
||||
intelligenceDoc.asn = 64500;
|
||||
intelligenceDoc.asnOrg = 'Example Network';
|
||||
intelligenceDoc.networkRange = '198.51.100.0 - 198.51.100.127';
|
||||
intelligenceDoc.firstSeenAt = Date.now();
|
||||
intelligenceDoc.lastSeenAt = Date.now();
|
||||
intelligenceDoc.updatedAt = Date.now();
|
||||
intelligenceDoc.seenCount = 1;
|
||||
await intelligenceDoc.save();
|
||||
|
||||
await manager.createBlockRule({
|
||||
type: 'asn',
|
||||
value: 'AS64500',
|
||||
reason: 'test asn range',
|
||||
});
|
||||
|
||||
const policy = await manager.compilePolicy();
|
||||
expect(policy.blockedCidrs).toEqual(['198.51.100.0/25']);
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager compiles intelligence CIDR arrays for ASN rules', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
const intelligenceDoc = new IpIntelligenceDoc();
|
||||
intelligenceDoc.ipAddress = '198.51.100.130';
|
||||
intelligenceDoc.asn = 64501;
|
||||
intelligenceDoc.asnOrg = 'Example Split Network';
|
||||
intelligenceDoc.networkRange = null;
|
||||
intelligenceDoc.networkCidrs = ['198.51.100.128/25', '198.51.101.0/24'];
|
||||
intelligenceDoc.firstSeenAt = Date.now();
|
||||
intelligenceDoc.lastSeenAt = Date.now();
|
||||
intelligenceDoc.updatedAt = Date.now();
|
||||
intelligenceDoc.seenCount = 1;
|
||||
await intelligenceDoc.save();
|
||||
|
||||
await manager.createBlockRule({
|
||||
type: 'asn',
|
||||
value: 'AS64501',
|
||||
reason: 'test asn cidr array',
|
||||
});
|
||||
|
||||
const policy = await manager.compilePolicy();
|
||||
expect(policy.blockedCidrs).toEqual(['198.51.100.128/25', '198.51.101.0/24']);
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
const firewall = await manager.compileRemoteIngressFirewall();
|
||||
expect(firewall).toEqual({ blockedIps: [] });
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager filters listed IP intelligence records', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
for (const [ipAddress, asn] of [['8.8.8.8', 15169], ['1.1.1.1', 13335]] as const) {
|
||||
const intelligenceDoc = new IpIntelligenceDoc();
|
||||
intelligenceDoc.ipAddress = ipAddress;
|
||||
intelligenceDoc.asn = asn;
|
||||
intelligenceDoc.asnOrg = `ASN ${asn}`;
|
||||
intelligenceDoc.firstSeenAt = Date.now();
|
||||
intelligenceDoc.lastSeenAt = Date.now();
|
||||
intelligenceDoc.updatedAt = Date.now();
|
||||
intelligenceDoc.seenCount = 1;
|
||||
await intelligenceDoc.save();
|
||||
}
|
||||
|
||||
const records = await manager.listIpIntelligence({ ipAddresses: ['1.1.1.1'] });
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].ipAddress).toEqual('1.1.1.1');
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager force refresh waits for an in-flight background observation', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager({ intelligenceRefreshMs: 0 });
|
||||
|
||||
let releaseFirstLookup!: () => void;
|
||||
let lookupCount = 0;
|
||||
(manager as any).smartNetwork = {
|
||||
getIpIntelligence: async () => {
|
||||
lookupCount++;
|
||||
if (lookupCount === 1) {
|
||||
await new Promise<void>((resolve) => { releaseFirstLookup = resolve; });
|
||||
return createIntelligenceResult(64500);
|
||||
}
|
||||
return createIntelligenceResult(64501);
|
||||
},
|
||||
stop: async () => {},
|
||||
};
|
||||
|
||||
const backgroundObservation = manager.observeIp('8.8.8.8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const forcedRefresh = manager.refreshIpIntelligence('8.8.8.8');
|
||||
releaseFirstLookup();
|
||||
|
||||
const record = await forcedRefresh;
|
||||
await backgroundObservation;
|
||||
|
||||
expect(lookupCount).toEqual(2);
|
||||
expect(record?.asn).toEqual(64501);
|
||||
});
|
||||
|
||||
tap.test('cleanup security policy test db', async () => {
|
||||
const dbHandle = await testDbPromise;
|
||||
await clearTestState();
|
||||
await dbHandle.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -5,6 +5,7 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const TEST_PORT = 3200;
|
||||
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||
const TEST_ADMIN_PASSWORD = 'test-admin-password';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
@@ -14,6 +15,7 @@ let adminIdentity: interfaces.data.IIdentity;
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
|
||||
testDcRouter = new DcRouter({
|
||||
opsServerPort: TEST_PORT,
|
||||
dbConfig: { enabled: false },
|
||||
@@ -31,7 +33,7 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
password: TEST_ADMIN_PASSWORD,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
|
||||
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
|
||||
import { TargetProfileManager } from '../ts/config/classes.target-profile-manager.js';
|
||||
|
||||
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
|
||||
const manager = new VpnManager({ forwardingMode: 'socket' });
|
||||
@@ -75,7 +77,7 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
|
||||
},
|
||||
} as any;
|
||||
(dcRouter as any).routeConfigManager = {
|
||||
setVpnClientIpsResolver: (resolver: unknown) => {
|
||||
setVpnClientAccessResolver: (resolver: unknown) => {
|
||||
resolverValues.push(resolver);
|
||||
},
|
||||
applyRoutes: async () => {
|
||||
@@ -107,4 +109,363 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
|
||||
expect(dcRouter.vpnManager).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN clients', async () => {
|
||||
const manager = new RouteConfigManager(() => undefined);
|
||||
const route = {
|
||||
name: 'private-route',
|
||||
vpnOnly: true,
|
||||
match: { domains: ['private.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
|
||||
security: { ipAllowList: ['*'] },
|
||||
} as any;
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual(['*']);
|
||||
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
undefined,
|
||||
() => ['client-1'],
|
||||
);
|
||||
const route = {
|
||||
name: 'private-route',
|
||||
vpnOnly: true,
|
||||
match: { domains: ['private.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
|
||||
security: {
|
||||
ipAllowList: ['*', '203.0.113.10'],
|
||||
ipBlockList: ['198.51.100.5'],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual(['*', '203.0.113.10']);
|
||||
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
|
||||
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
undefined,
|
||||
() => ['client-1'],
|
||||
);
|
||||
const route = {
|
||||
name: 'shared-private-route',
|
||||
match: { domains: ['app.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
|
||||
security: {
|
||||
ipAllowList: ['203.0.113.10'],
|
||||
ipBlockList: ['198.51.100.5'],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10']);
|
||||
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
|
||||
expect(prepared.security.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'hagen.team VPN access',
|
||||
domains: ['*.hagen.team'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'hagen-app',
|
||||
match: { domains: 'app.hagen.team', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'hagen.team VPN access',
|
||||
domains: ['*.hagen.team'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const routes = new Map([
|
||||
['route-1', {
|
||||
id: 'route-1',
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
route: {
|
||||
name: 'hagen-app',
|
||||
match: { domains: 'app.hagen.team', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
},
|
||||
}],
|
||||
]) as any;
|
||||
|
||||
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
|
||||
|
||||
expect(accessSpec.domains).toContain('*.hagen.team');
|
||||
expect(accessSpec.domains).toContain('app.hagen.team');
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'restricted-public-route',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: { ipAllowList: ['203.0.113.10'] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager leaves real source-IP enforcement to SmartProxy', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'restricted-public-route',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: { ipAllowList: ['203.0.113.10'] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager does not grant routes with wildcard source block', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'blocked-route',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: {
|
||||
ipAllowList: ['203.0.113.0/24'],
|
||||
ipBlockList: ['*'],
|
||||
},
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'public-route',
|
||||
match: { domains: 'public.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager grants vpnOnly routes through source-policy profiles', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'vpn-only-route',
|
||||
vpnOnly: true,
|
||||
match: { domains: 'private.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: { ipAllowList: ['203.0.113.10'] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const routes = new Map([
|
||||
['route-1', {
|
||||
id: 'route-1',
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
route: {
|
||||
name: 'source-reachable-app',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: { ipAllowList: ['203.0.113.0/24'] },
|
||||
},
|
||||
}],
|
||||
]) as any;
|
||||
|
||||
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
|
||||
|
||||
expect(accessSpec.domains).toContain('app.example.com');
|
||||
});
|
||||
|
||||
tap.test('VpnManager normalizes real remote addresses', async () => {
|
||||
expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10');
|
||||
expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1');
|
||||
expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1');
|
||||
});
|
||||
|
||||
tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => {
|
||||
const manager = new VpnManager({});
|
||||
let sourceIpChangeCalls = 0;
|
||||
(manager as any).config.onClientSourceIpsChanged = () => {
|
||||
sourceIpChangeCalls++;
|
||||
};
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }],
|
||||
]);
|
||||
(manager as any).vpnServer = {
|
||||
listClients: async () => ([
|
||||
{
|
||||
clientId: 'runtime-client-1',
|
||||
registeredClientId: 'client-1',
|
||||
assignedIp: '10.8.0.2',
|
||||
transportType: 'wireguard',
|
||||
},
|
||||
]),
|
||||
listWgPeers: async () => ([
|
||||
{
|
||||
publicKey: 'wg-public-key',
|
||||
allowedIps: ['10.8.0.2/32'],
|
||||
endpoint: '198.51.100.44:61234',
|
||||
bytesSent: 0,
|
||||
bytesReceived: 0,
|
||||
packetsSent: 0,
|
||||
packetsReceived: 0,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const changed = await manager.refreshClientSourceIps();
|
||||
const changedAgain = await manager.refreshClientSourceIps();
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
expect(changedAgain).toEqual(false);
|
||||
expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44');
|
||||
expect(sourceIpChangeCalls).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
|
||||
const manager = new VpnManager({
|
||||
serverEndpoint: 'vpn.example.com',
|
||||
getClientAllowedIPs: async () => ['10.8.0.0/24', '203.0.113.10/32'],
|
||||
});
|
||||
|
||||
(manager as any).vpnServer = {
|
||||
rotateClientKey: async () => ({
|
||||
entry: {
|
||||
clientId: 'client-1',
|
||||
publicKey: 'noise-public-key',
|
||||
wgPublicKey: 'wg-public-key',
|
||||
},
|
||||
wireguardConfig: '[Interface]\nPrivateKey = old\nAddress = 10.8.0.2/24\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.com:51820\n',
|
||||
secrets: { noisePrivateKey: 'noise-private-key', wgPrivateKey: 'wg-private-key' },
|
||||
}),
|
||||
};
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { clientId: 'client-1', targetProfileIds: ['profile-1'] }],
|
||||
]);
|
||||
(manager as any).persistClient = async () => {};
|
||||
|
||||
const bundle = await manager.rotateClientKey('client-1');
|
||||
|
||||
expect(bundle.wireguardConfig).toContain('AllowedIPs = 10.8.0.0/24, 203.0.113.10/32');
|
||||
});
|
||||
|
||||
export default tap.start()
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { WorkAppMailManager } from '../ts/email/classes.workapp-mail-manager.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
class MemoryStorageManager {
|
||||
public store = new Map<string, string>();
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
return this.store.get(key) || null;
|
||||
}
|
||||
|
||||
public async set(key: string, value: string): Promise<void> {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const createDcRouterStub = () => {
|
||||
const storageManager = new MemoryStorageManager();
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587, 465],
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'operator-route',
|
||||
match: { recipients: 'ops@example.com' },
|
||||
action: { type: 'reject', reject: { code: 550, message: 'not here' } },
|
||||
},
|
||||
],
|
||||
auth: {
|
||||
users: [{ username: 'operator', password: 'secret' }],
|
||||
},
|
||||
};
|
||||
const dcRouterRef: any = {
|
||||
storageManager,
|
||||
options: { emailConfig },
|
||||
emailServer: {
|
||||
updateOptions: (patch: Partial<IUnifiedEmailServerOptions>) => {
|
||||
dcRouterRef.options.emailConfig = {
|
||||
...dcRouterRef.options.emailConfig,
|
||||
...patch,
|
||||
};
|
||||
},
|
||||
},
|
||||
updateEmailRoutes: async (routes: IUnifiedEmailServerOptions['routes']) => {
|
||||
dcRouterRef.options.emailConfig.routes = routes;
|
||||
},
|
||||
};
|
||||
return { dcRouterRef, storageManager };
|
||||
};
|
||||
|
||||
tap.test('WorkAppMailManager syncs SMTP identity and inbound smartmta route', async () => {
|
||||
const { dcRouterRef } = createDcRouterStub();
|
||||
const manager = new WorkAppMailManager(dcRouterRef);
|
||||
|
||||
const createResult = await manager.syncMailIdentity({
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
},
|
||||
localPart: 'Hello',
|
||||
domain: 'Example.com',
|
||||
inbound: {
|
||||
enabled: true,
|
||||
targetHost: '10.0.0.2',
|
||||
targetPort: 2525,
|
||||
},
|
||||
}, 'tester');
|
||||
|
||||
expect(createResult.success).toEqual(true);
|
||||
expect(createResult.action).toEqual('created');
|
||||
expect(createResult.identity?.address).toEqual('hello@example.com');
|
||||
expect(createResult.identity?.smtp.username.startsWith('workapp-')).toEqual(true);
|
||||
expect((createResult.identity as any).smtpPassword).toBeUndefined();
|
||||
expect(createResult.smtpCredentials?.password.length).toBeGreaterThan(20);
|
||||
|
||||
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
|
||||
expect(generatedRoute.match.recipients).toEqual('hello@example.com');
|
||||
expect(generatedRoute.action.forward.host).toEqual('10.0.0.2');
|
||||
expect(generatedRoute.action.forward.port).toEqual(2525);
|
||||
expect(generatedRoute.action.forward.addHeaders['X-Dcrouter-WorkApp-Id']).toEqual('app-1');
|
||||
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name === 'operator-route')).toEqual(true);
|
||||
|
||||
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
|
||||
expect(generatedUser.password).toEqual(createResult.smtpCredentials?.password);
|
||||
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
|
||||
|
||||
const listResult = await manager.listMailIdentities({ workAppId: 'app-1' });
|
||||
expect(listResult.length).toEqual(1);
|
||||
expect(listResult[0].address).toEqual('hello@example.com');
|
||||
});
|
||||
|
||||
tap.test('WorkAppMailManager updates, resets credentials, and deletes identities', async () => {
|
||||
const { dcRouterRef } = createDcRouterStub();
|
||||
const manager = new WorkAppMailManager(dcRouterRef);
|
||||
const ownership = {
|
||||
workHosterType: 'onebox' as const,
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
};
|
||||
|
||||
const createResult = await manager.syncMailIdentity({
|
||||
ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
|
||||
}, 'tester');
|
||||
const firstPassword = createResult.smtpCredentials!.password;
|
||||
|
||||
const updateResult = await manager.syncMailIdentity({
|
||||
ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
inbound: { enabled: true, targetHost: '10.0.0.3', targetPort: 2526 },
|
||||
}, 'tester');
|
||||
expect(updateResult.action).toEqual('updated');
|
||||
expect(updateResult.smtpCredentials).toBeUndefined();
|
||||
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
|
||||
expect(generatedUser.password).toEqual(firstPassword);
|
||||
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
|
||||
expect(generatedRoute.action.forward.host).toEqual('10.0.0.3');
|
||||
|
||||
const resetResult = await manager.syncMailIdentity({
|
||||
ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
resetSmtpPassword: true,
|
||||
}, 'tester');
|
||||
expect(resetResult.smtpCredentials?.password !== firstPassword).toEqual(true);
|
||||
|
||||
const deleteResult = await manager.syncMailIdentity({
|
||||
ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
delete: true,
|
||||
}, 'tester');
|
||||
expect(deleteResult.action).toEqual('deleted');
|
||||
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
|
||||
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username.startsWith('workapp-'))).toEqual(false);
|
||||
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('WorkAppMailManager applies persisted identities to startup email config', async () => {
|
||||
const { dcRouterRef } = createDcRouterStub();
|
||||
const manager = new WorkAppMailManager(dcRouterRef);
|
||||
await manager.syncMailIdentity({
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
},
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
|
||||
}, 'tester');
|
||||
|
||||
const baseStartupConfig: IUnifiedEmailServerOptions = {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25],
|
||||
domains: [{ domain: 'example.com', dnsMode: 'external-dns' }],
|
||||
routes: [],
|
||||
};
|
||||
const startupConfig = await manager.applyStoredIdentitiesToEmailConfig(baseStartupConfig);
|
||||
|
||||
expect(startupConfig.routes.some((route) => route.name.startsWith('workapp-mail-'))).toEqual(true);
|
||||
expect(startupConfig.auth?.users?.some((user) => user.username.startsWith('workapp-'))).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,565 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { WorkHosterHandler } from '../ts/opsserver/handlers/workhoster.handler.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
type TScope = interfaces.data.TApiTokenScope;
|
||||
|
||||
const fireTypedRequest = async (
|
||||
router: plugins.typedrequest.TypedRouter,
|
||||
method: string,
|
||||
request: Record<string, any>,
|
||||
) => {
|
||||
return await router.routeAndAddResponse({
|
||||
method,
|
||||
request,
|
||||
response: {},
|
||||
correlation: {
|
||||
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
phase: 'request',
|
||||
},
|
||||
} as any, { localRequest: true, skipHooks: true }) as any;
|
||||
};
|
||||
|
||||
const makeApiTokenManager = (
|
||||
scopes: TScope[],
|
||||
policy?: interfaces.data.IApiTokenPolicy,
|
||||
) => {
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'workhoster-test-token',
|
||||
scopes,
|
||||
createdBy: 'token-user',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
policy,
|
||||
} as interfaces.data.IStoredApiToken;
|
||||
|
||||
return {
|
||||
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
|
||||
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => {
|
||||
if (storedToken.policy?.role === 'admin') return true;
|
||||
const isGatewayClientToken = storedToken.policy?.role === 'gatewayClient';
|
||||
const gatewayClientAllowedScopes = new Set<TScope>([
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
]);
|
||||
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) return false;
|
||||
if (!isGatewayClientToken && storedToken.scopes.includes('*')) return true;
|
||||
const scopes = new Set(storedToken.scopes);
|
||||
for (const policyScope of storedToken.policy?.scopes || []) {
|
||||
scopes.add(policyScope);
|
||||
}
|
||||
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
|
||||
'gateway-clients:read': ['workhosters:read'],
|
||||
'gateway-clients:write': ['workhosters:write'],
|
||||
'workhosters:read': ['gateway-clients:read'],
|
||||
'workhosters:write': ['gateway-clients:write'],
|
||||
};
|
||||
return scopes.has(scope) || Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const makeRouteConfigManager = () => {
|
||||
const routes = new Map<string, interfaces.data.IRoute>();
|
||||
let nextRouteNumber = 1;
|
||||
|
||||
return {
|
||||
routes,
|
||||
manager: {
|
||||
findApiRouteByExternalKey: (externalKey: string) => {
|
||||
return Array.from(routes.values()).find((route) =>
|
||||
route.origin === 'api' && route.metadata?.externalKey === externalKey,
|
||||
);
|
||||
},
|
||||
createRoute: async (
|
||||
route: interfaces.data.IDcRouterRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
metadata?: interfaces.data.IRouteMetadata,
|
||||
) => {
|
||||
const id = `route-${nextRouteNumber++}`;
|
||||
routes.set(id, {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdBy,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
origin: 'api',
|
||||
metadata,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
updateRoute: async (
|
||||
id: string,
|
||||
patch: {
|
||||
route?: Partial<interfaces.data.IDcRouterRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<interfaces.data.IRouteMetadata>;
|
||||
},
|
||||
) => {
|
||||
const storedRoute = routes.get(id);
|
||||
if (!storedRoute) return { success: false, message: 'Route not found' };
|
||||
if (patch.route) {
|
||||
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
storedRoute.enabled = patch.enabled;
|
||||
}
|
||||
if (patch.metadata) {
|
||||
storedRoute.metadata = { ...storedRoute.metadata, ...patch.metadata };
|
||||
}
|
||||
storedRoute.updatedAt = Date.now();
|
||||
return { success: true };
|
||||
},
|
||||
deleteRoute: async (id: string) => {
|
||||
const deleted = routes.delete(id);
|
||||
return deleted ? { success: true } : { success: false, message: 'Route not found' };
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const setupHandler = (options: {
|
||||
scopes: TScope[];
|
||||
policy?: interfaces.data.IApiTokenPolicy;
|
||||
isAdmin?: boolean;
|
||||
dcRouterRef?: Record<string, any>;
|
||||
}) => {
|
||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
validateIdentity: async (identity: interfaces.data.IIdentity) => options.isAdmin
|
||||
? { ...identity, role: 'admin' }
|
||||
: identity,
|
||||
adminIdentityGuard: {
|
||||
exec: async () => Boolean(options.isAdmin),
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
|
||||
...options.dcRouterRef,
|
||||
},
|
||||
};
|
||||
|
||||
new WorkHosterHandler(opsServerRef);
|
||||
return { typedrouter, opsServerRef };
|
||||
};
|
||||
|
||||
tap.test('WorkHosterHandler exposes capabilities and managed domains with workhosters:read', async () => {
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:read'],
|
||||
dcRouterRef: {
|
||||
options: {
|
||||
remoteIngressConfig: { enabled: true },
|
||||
dnsScopes: ['example.com'],
|
||||
http3: { enabled: false },
|
||||
},
|
||||
routeConfigManager: {
|
||||
getMergedRoutes: () => ({ routes: [] }),
|
||||
},
|
||||
smartProxy: {},
|
||||
emailDomainManager: {},
|
||||
emailServer: {},
|
||||
dnsManager: {
|
||||
listDomains: async () => [
|
||||
{ id: 'domain-1', name: 'example.com', source: 'dcrouter', authoritative: true },
|
||||
{ id: 'domain-2', name: 'provider.example', source: 'provider', providerId: 'cloudflare-1', authoritative: false },
|
||||
],
|
||||
toPublicDomain: (domainDoc: any) => ({
|
||||
...domainDoc,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const capabilitiesResult = await fireTypedRequest(typedrouter, 'getGatewayCapabilities', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
expect(capabilitiesResult.error).toBeUndefined();
|
||||
expect(capabilitiesResult.response.capabilities.routes.idempotentSync).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.domains.read).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.certificates.export).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.email.inbound).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.remoteIngress.enabled).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.dns.authoritative).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.http3.enabled).toEqual(false);
|
||||
|
||||
const domainsResult = await fireTypedRequest(typedrouter, 'getWorkHosterDomains', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
expect(domainsResult.error).toBeUndefined();
|
||||
expect(domainsResult.response.domains.length).toEqual(2);
|
||||
expect(domainsResult.response.domains[0].capabilities.canCreateSubdomains).toEqual(true);
|
||||
expect(domainsResult.response.domains[1].capabilities.canManageDnsRecords).toEqual(true);
|
||||
expect(domainsResult.response.domains[1].capabilities.canIssueCertificates).toEqual(true);
|
||||
expect(domainsResult.response.domains[1].capabilities.canHostEmail).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:write', async () => {
|
||||
const routeConfig = makeRouteConfigManager();
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:write'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
routeConfigManager: routeConfig.manager,
|
||||
},
|
||||
});
|
||||
const ownership: interfaces.data.IWorkAppRouteOwnership = {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
};
|
||||
|
||||
const createResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership,
|
||||
route: {
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.2', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResult.error).toBeUndefined();
|
||||
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.size).toEqual(1);
|
||||
|
||||
const createdRoute = routeConfig.routes.get('route-1')!;
|
||||
expect(createdRoute.createdBy).toEqual('token-user');
|
||||
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
|
||||
expect(createdRoute.metadata).toEqual({
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
gatewayClientAppId: 'app-1',
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
externalKey: 'onebox:box-1:app-1:app.example.com',
|
||||
});
|
||||
|
||||
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership,
|
||||
enabled: false,
|
||||
route: {
|
||||
name: 'updated-workapp-route',
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.3', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResult.error).toBeUndefined();
|
||||
expect(updateResult.response).toEqual({ success: true, action: 'updated', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.size).toEqual(1);
|
||||
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
|
||||
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
|
||||
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
|
||||
|
||||
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership,
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(deleteResult.error).toBeUndefined();
|
||||
expect(deleteResult.response).toEqual({ success: true, action: 'deleted', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.size).toEqual(0);
|
||||
|
||||
const unchangedResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership,
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(unchangedResult.error).toBeUndefined();
|
||||
expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' });
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler exposes gateway client context for token-bound clients', async () => {
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:read'],
|
||||
policy: {
|
||||
role: 'gatewayClient',
|
||||
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
||||
hostnamePatterns: ['*.example.com'],
|
||||
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
|
||||
capabilities: {
|
||||
readDomains: true,
|
||||
readDnsRecords: true,
|
||||
syncRoutes: true,
|
||||
},
|
||||
},
|
||||
dcRouterRef: { options: {} },
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'getGatewayClientContext', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.context.gatewayClient).toEqual({ type: 'onebox', id: 'box-policy' });
|
||||
expect(result.response.context.hostnamePatterns).toEqual(['*.example.com']);
|
||||
expect(result.response.context.capabilities.syncRoutes).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler derives route ownership from gateway client token policy', async () => {
|
||||
const routeConfig = makeRouteConfigManager();
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:write'],
|
||||
policy: {
|
||||
role: 'gatewayClient',
|
||||
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
||||
hostnamePatterns: ['*.example.com'],
|
||||
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
|
||||
capabilities: { syncRoutes: true },
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
routeConfigManager: routeConfig.manager,
|
||||
},
|
||||
});
|
||||
|
||||
const createResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
appId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
},
|
||||
route: {
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.2', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResult.error).toBeUndefined();
|
||||
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.get('route-1')?.metadata?.gatewayClientId).toEqual('box-policy');
|
||||
expect(routeConfig.routes.get('route-1')?.metadata?.externalKey).toEqual('onebox:box-policy:app-1:app.example.com');
|
||||
|
||||
const spoofResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'other-box',
|
||||
appId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
},
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(spoofResult.error?.text).toEqual('gateway client token cannot act for this ownership');
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler manages durable gateway clients and creates scoped tokens', async () => {
|
||||
const identity: interfaces.data.IIdentity = {
|
||||
jwt: 'admin-jwt',
|
||||
userId: 'admin-user',
|
||||
name: 'admin',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
const gatewayClient: interfaces.data.IGatewayClient = {
|
||||
id: 'onebox-main',
|
||||
type: 'onebox',
|
||||
name: 'Main Onebox',
|
||||
hostnamePatterns: ['*.apps.example.com'],
|
||||
allowedRouteTargets: [{ host: 'onebox-smartproxy', ports: [80] }],
|
||||
capabilities: { readDomains: true, readDnsRecords: true, syncRoutes: true },
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'admin-user',
|
||||
};
|
||||
let createdTokenPolicy: interfaces.data.IApiTokenPolicy | undefined;
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: [],
|
||||
isAdmin: true,
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
gatewayClientManager: {
|
||||
listClients: async () => [gatewayClient],
|
||||
getClient: async (id: string) => id === gatewayClient.id ? gatewayClient : null,
|
||||
},
|
||||
apiTokenManager: {
|
||||
listTokens: () => [{
|
||||
id: 'token-1',
|
||||
name: 'token',
|
||||
scopes: ['gateway-clients:read'],
|
||||
policy: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-main' } },
|
||||
createdAt: 1,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
}],
|
||||
createToken: async (
|
||||
_name: string,
|
||||
_scopes: TScope[],
|
||||
_expiresInDays: number | null,
|
||||
_createdBy: string,
|
||||
policy?: interfaces.data.IApiTokenPolicy,
|
||||
) => {
|
||||
createdTokenPolicy = policy;
|
||||
return { id: 'new-token', rawToken: 'dcr_created' };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listResult = await fireTypedRequest(typedrouter, 'listGatewayClients', { identity });
|
||||
expect(listResult.error).toBeUndefined();
|
||||
expect(listResult.response.gatewayClients[0].tokenCount).toEqual(1);
|
||||
|
||||
const tokenResult = await fireTypedRequest(typedrouter, 'createGatewayClientToken', {
|
||||
identity,
|
||||
gatewayClientId: 'onebox-main',
|
||||
});
|
||||
expect(tokenResult.error).toBeUndefined();
|
||||
expect(tokenResult.response.tokenValue).toEqual('dcr_created');
|
||||
expect(createdTokenPolicy?.gatewayClient).toEqual({ type: 'onebox', id: 'onebox-main' });
|
||||
expect(createdTokenPolicy?.allowedRouteTargets).toEqual([{ host: 'onebox-smartproxy', ports: [80] }]);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => {
|
||||
const routeConfig = makeRouteConfigManager();
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:read'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
routeConfigManager: routeConfig.manager,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
},
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
expect(routeConfig.routes.size).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler exposes and syncs WorkApp mail identities', async () => {
|
||||
const syncedRequests: Array<{ data: any; userId: string }> = [];
|
||||
const identity: interfaces.data.IWorkAppMailIdentity = {
|
||||
id: 'mail-1',
|
||||
externalKey: 'onebox:box-1:app-1:hello@example.com',
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
},
|
||||
address: 'hello@example.com',
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
enabled: true,
|
||||
inbound: {
|
||||
enabled: true,
|
||||
targetHost: '10.0.0.2',
|
||||
targetPort: 2525,
|
||||
},
|
||||
smtp: {
|
||||
enabled: true,
|
||||
username: 'workapp-user',
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'token-user',
|
||||
};
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:read', 'workhosters:write'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
workAppMailManager: {
|
||||
listMailIdentities: async (filter: any) => filter.workAppId === 'app-1' ? [identity] : [],
|
||||
syncMailIdentity: async (data: any, userId: string) => {
|
||||
syncedRequests.push({ data, userId });
|
||||
return {
|
||||
success: true,
|
||||
action: 'created',
|
||||
identity,
|
||||
smtpCredentials: {
|
||||
username: 'workapp-user',
|
||||
password: 'generated-password',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listResult = await fireTypedRequest(typedrouter, 'getWorkAppMailIdentities', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: { workAppId: 'app-1' },
|
||||
});
|
||||
expect(listResult.error).toBeUndefined();
|
||||
expect(listResult.response.identities).toEqual([identity]);
|
||||
|
||||
const syncResult = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: identity.ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
inbound: identity.inbound,
|
||||
});
|
||||
expect(syncResult.error).toBeUndefined();
|
||||
expect(syncResult.response.success).toEqual(true);
|
||||
expect(syncResult.response.smtpCredentials.password).toEqual('generated-password');
|
||||
expect(syncedRequests[0].userId).toEqual('token-user');
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write', async () => {
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:read'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
workAppMailManager: {
|
||||
syncMailIdentity: async () => ({ success: true }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
},
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
node --input-type=module <<'NODE'
|
||||
import fs from 'node:fs';
|
||||
|
||||
const readJson = (path) => JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
|
||||
const checks = {
|
||||
packageVersion: readJson('/app/package.json').version,
|
||||
interfacesVersion: readJson('/app/node_modules/@serve.zone/interfaces/package.json').version,
|
||||
remoteingressVersion: readJson('/app/node_modules/@serve.zone/remoteingress/package.json').version,
|
||||
hasCli: fs.existsSync('/app/cli.js'),
|
||||
hasWebBundle: fs.existsSync('/app/dist_serve/bundle.js'),
|
||||
};
|
||||
|
||||
await import('/app/dist_ts/index.js');
|
||||
|
||||
if (checks.packageVersion !== '13.25.0') {
|
||||
throw new Error(`Unexpected dcrouter package version ${checks.packageVersion}`);
|
||||
}
|
||||
if (checks.interfacesVersion !== '5.4.6') {
|
||||
throw new Error(`Unexpected interfaces version ${checks.interfacesVersion}`);
|
||||
}
|
||||
if (checks.remoteingressVersion !== '4.17.1') {
|
||||
throw new Error(`Unexpected remoteingress version ${checks.remoteingressVersion}`);
|
||||
}
|
||||
if (!checks.hasCli) {
|
||||
throw new Error('Missing cli.js');
|
||||
}
|
||||
if (!checks.hasWebBundle) {
|
||||
throw new Error('Missing web bundle');
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(checks));
|
||||
NODE
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.22.0',
|
||||
version: '13.36.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+141
-22
@@ -25,14 +25,15 @@ import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
|
||||
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -166,6 +167,14 @@ export interface IDcRouterOptions {
|
||||
/** Port for the OpsServer web UI (default: 3000) */
|
||||
opsServerPort?: number;
|
||||
|
||||
/** Optional OpsServer account authentication settings. */
|
||||
adminAuth?: {
|
||||
/** Optional idp.global password-authentication URL override. Defaults to the SDK's hosted https://idp.global endpoint. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
|
||||
idpGlobalUrl?: string;
|
||||
/** Test/integration hook for injecting an idp.global-compatible password client. */
|
||||
idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
|
||||
};
|
||||
|
||||
remoteIngressConfig?: {
|
||||
/** Enable remote ingress hub (default: false) */
|
||||
enabled?: boolean;
|
||||
@@ -275,6 +284,7 @@ export class DcRouter {
|
||||
// Programmatic config API
|
||||
public routeConfigManager?: RouteConfigManager;
|
||||
public apiTokenManager?: ApiTokenManager;
|
||||
public gatewayClientManager?: GatewayClientManager;
|
||||
public referenceResolver?: ReferenceResolver;
|
||||
public targetProfileManager?: TargetProfileManager;
|
||||
|
||||
@@ -284,6 +294,8 @@ export class DcRouter {
|
||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||
public acmeConfigManager?: AcmeConfigManager;
|
||||
public emailDomainManager?: EmailDomainManager;
|
||||
public workAppMailManager: WorkAppMailManager;
|
||||
public securityPolicyManager?: SecurityPolicyManager;
|
||||
|
||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||
public detectedPublicIp: string | null = null;
|
||||
@@ -337,6 +349,7 @@ export class DcRouter {
|
||||
this.storageManager = new SmartMtaStorageManager(
|
||||
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
||||
);
|
||||
this.workAppMailManager = new WorkAppMailManager(this);
|
||||
|
||||
// Initialize service manager and register all services
|
||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||
@@ -471,12 +484,36 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence
|
||||
// and compiles the global block policy for SmartProxy and remote ingress edges.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SecurityPolicyManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.securityPolicyManager = new SecurityPolicyManager({
|
||||
onPolicyChanged: () => this.applySecurityPolicy(),
|
||||
});
|
||||
await this.securityPolicyManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.securityPolicyManager) {
|
||||
await this.securityPolicyManager.stop();
|
||||
this.securityPolicyManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||
const smartProxyDeps: string[] = [];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
smartProxyDeps.push('DcRouterDb');
|
||||
smartProxyDeps.push('DnsManager');
|
||||
smartProxyDeps.push('AcmeConfigManager');
|
||||
smartProxyDeps.push('SecurityPolicyManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartProxy')
|
||||
@@ -568,7 +605,7 @@ 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
|
||||
@@ -589,6 +626,8 @@ export class DcRouter {
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
await this.apiTokenManager.initialize();
|
||||
this.gatewayClientManager = new GatewayClientManager();
|
||||
await this.gatewayClientManager.initialize();
|
||||
await this.routeConfigManager.initialize(
|
||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
@@ -606,6 +645,7 @@ export class DcRouter {
|
||||
.withStop(async () => {
|
||||
this.routeConfigManager = undefined;
|
||||
this.apiTokenManager = undefined;
|
||||
this.gatewayClientManager = undefined;
|
||||
this.referenceResolver = undefined;
|
||||
this.targetProfileManager = undefined;
|
||||
})
|
||||
@@ -708,10 +748,14 @@ export class DcRouter {
|
||||
|
||||
// VPN Server: optional, depends on SmartProxy
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
const vpnServiceDeps = ['SmartProxy'];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
vpnServiceDeps.push('ConfigManagers');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('VpnServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn(...vpnServiceDeps)
|
||||
.withStart(async () => {
|
||||
await this.setupVpnServer();
|
||||
})
|
||||
@@ -971,6 +1015,12 @@ export class DcRouter {
|
||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||
}
|
||||
|
||||
const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy();
|
||||
const mergedSecurityPolicy = this.mergeSecurityPolicies(
|
||||
(this.options.smartProxyConfig as any)?.securityPolicy,
|
||||
compiledSecurityPolicy,
|
||||
);
|
||||
|
||||
// If we have routes or need a basic SmartProxy instance, create it
|
||||
if (routes.length > 0 || this.options.smartProxyConfig) {
|
||||
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||
@@ -1002,6 +1052,7 @@ export class DcRouter {
|
||||
// --- always set by dcrouter (after spread) ---
|
||||
routes,
|
||||
acme: acmeConfig,
|
||||
...(mergedSecurityPolicy ? { securityPolicy: mergedSecurityPolicy } as any : {}),
|
||||
certStore: {
|
||||
loadAll: async () => {
|
||||
const docs = await ProxyCertDoc.findAll();
|
||||
@@ -1066,6 +1117,7 @@ export class DcRouter {
|
||||
});
|
||||
|
||||
const scheduler = this.certProvisionScheduler;
|
||||
smartProxyConfig.certProvisionFallbackToAcme = false;
|
||||
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
|
||||
// If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01
|
||||
if (!this.smartAcmeReady) {
|
||||
@@ -1114,10 +1166,10 @@ export class DcRouter {
|
||||
await scheduler.clearBackoff(domain);
|
||||
return result;
|
||||
} catch (err: unknown) {
|
||||
// Record failure for backoff tracking
|
||||
await scheduler.recordFailure(domain, (err as Error).message);
|
||||
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`);
|
||||
return 'http01';
|
||||
const message = `DNS-01 failed for ${domain}: ${(err as Error).message}`;
|
||||
await scheduler.recordFailure(domain, message);
|
||||
eventComms.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1244,8 +1296,60 @@ export class DcRouter {
|
||||
logger.log('info', `SmartProxy started with ${routes.length} routes`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async applySecurityPolicy(): Promise<void> {
|
||||
if (!this.securityPolicyManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const compiledSmartProxyPolicy = await this.securityPolicyManager.compileSmartProxyPolicy();
|
||||
const mergedSecurityPolicy = this.mergeSecurityPolicies(
|
||||
(this.options.smartProxyConfig as any)?.securityPolicy,
|
||||
compiledSmartProxyPolicy,
|
||||
);
|
||||
|
||||
if (this.smartProxy && mergedSecurityPolicy) {
|
||||
const smartProxyWithPolicyApi = this.smartProxy as any;
|
||||
if (typeof smartProxyWithPolicyApi.updateSecurityPolicy === 'function') {
|
||||
await smartProxyWithPolicyApi.updateSecurityPolicy(mergedSecurityPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
const firewallConfig = await this.securityPolicyManager.compileRemoteIngressFirewall();
|
||||
if (this.remoteIngressManager) {
|
||||
(this.remoteIngressManager as any).setFirewallConfig?.(firewallConfig);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
}
|
||||
|
||||
private mergeSecurityPolicies(
|
||||
...policies: Array<Partial<ISecurityCompiledPolicy> | undefined>
|
||||
): ISecurityCompiledPolicy | undefined {
|
||||
const blockedIps = new Set<string>();
|
||||
const blockedCidrs = new Set<string>();
|
||||
|
||||
for (const policy of policies) {
|
||||
for (const ip of policy?.blockedIps || []) {
|
||||
if (ip) blockedIps.add(ip);
|
||||
}
|
||||
for (const cidr of policy?.blockedCidrs || []) {
|
||||
if (cidr) blockedCidrs.add(cidr);
|
||||
}
|
||||
}
|
||||
|
||||
if (blockedIps.size === 0 && blockedCidrs.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
blockedIps: [...blockedIps].sort(),
|
||||
blockedCidrs: [...blockedCidrs].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Generate SmartProxy routes for email configuration
|
||||
@@ -1545,7 +1649,7 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// Create config with mapped ports
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
|
||||
...this.options.emailConfig,
|
||||
domains: transformedDomains,
|
||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
||||
@@ -1555,7 +1659,7 @@ export class DcRouter {
|
||||
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
||||
...this.options.emailConfig.queue,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Create unified email server
|
||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||
@@ -2232,6 +2336,9 @@ export class DcRouter {
|
||||
// Initialize the edge registration manager
|
||||
this.remoteIngressManager = new RemoteIngressManager();
|
||||
await this.remoteIngressManager.initialize();
|
||||
this.remoteIngressManager.setFirewallConfig(
|
||||
await this.securityPolicyManager?.compileRemoteIngressFirewall(),
|
||||
);
|
||||
|
||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||
@@ -2292,10 +2399,10 @@ export class DcRouter {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -2309,7 +2416,7 @@ export class DcRouter {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
return this.targetProfileManager.getMatchingVpnClients(
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
@@ -2323,6 +2430,13 @@ export class DcRouter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.dbConfig?.enabled === false) {
|
||||
throw new Error('VPN requires dbConfig.enabled because clients, keys, routes, and target profiles are persisted in DcRouterDb');
|
||||
}
|
||||
if (!this.routeConfigManager || !this.targetProfileManager) {
|
||||
throw new Error('VPN requires initialized route and target profile managers');
|
||||
}
|
||||
|
||||
logger.log('info', 'Setting up VPN server...');
|
||||
|
||||
this.vpnManager = new VpnManager({
|
||||
@@ -2338,17 +2452,21 @@ export class DcRouter {
|
||||
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
|
||||
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||
onClientChanged: () => {
|
||||
// Re-apply routes so profile-based ipAllowLists get updated
|
||||
// Re-apply routes so profile-based VPN client grants get updated
|
||||
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
|
||||
this.routeConfigManager?.applyRoutes().catch((err) => {
|
||||
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
|
||||
});
|
||||
},
|
||||
onClientSourceIpsChanged: () => {
|
||||
// SmartProxy now receives the real source IP per connection via PROXY v2.
|
||||
// Source-IP changes are reflected in status/UI only; route config is static.
|
||||
},
|
||||
getClientDirectTargets: (targetProfileIds: string[]) => {
|
||||
if (!this.targetProfileManager) return [];
|
||||
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
|
||||
},
|
||||
getClientAllowedIPs: async (targetProfileIds: string[]) => {
|
||||
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, _sourceIp?: string) => {
|
||||
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||
const ips = new Set<string>([subnet]);
|
||||
|
||||
@@ -2357,7 +2475,8 @@ export class DcRouter {
|
||||
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
|
||||
|
||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||
targetProfileIds, allRoutes,
|
||||
targetProfileIds,
|
||||
allRoutes,
|
||||
);
|
||||
|
||||
// Add target IPs directly
|
||||
@@ -2384,7 +2503,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();
|
||||
}
|
||||
|
||||
@@ -2480,7 +2599,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();
|
||||
|
||||
@@ -2,12 +2,15 @@ import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { ApiTokenDoc } from '../db/index.js';
|
||||
import type {
|
||||
IApiTokenPolicy,
|
||||
IStoredApiToken,
|
||||
IApiTokenInfo,
|
||||
TApiTokenScope,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const TOKEN_PREFIX_STR = 'dcr_';
|
||||
const ENV_ADMIN_TOKEN_ID = 'env-admin-token';
|
||||
const ENV_ADMIN_TOKEN_CREATED_BY = 'dcrouter-env';
|
||||
|
||||
export class ApiTokenManager {
|
||||
private tokens = new Map<string, IStoredApiToken>();
|
||||
@@ -16,6 +19,7 @@ export class ApiTokenManager {
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadTokens();
|
||||
await this.ensureEnvAdminToken();
|
||||
if (this.tokens.size > 0) {
|
||||
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
|
||||
}
|
||||
@@ -33,13 +37,14 @@ export class ApiTokenManager {
|
||||
scopes: TApiTokenScope[],
|
||||
expiresInDays: number | null,
|
||||
createdBy: string,
|
||||
policy?: IApiTokenPolicy,
|
||||
): Promise<{ id: string; rawToken: string }> {
|
||||
const id = plugins.uuid.v4();
|
||||
const randomBytes = plugins.crypto.randomBytes(32);
|
||||
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||
|
||||
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const tokenHash = this.hashToken(rawToken);
|
||||
|
||||
const now = Date.now();
|
||||
const stored: IStoredApiToken = {
|
||||
@@ -47,6 +52,7 @@ export class ApiTokenManager {
|
||||
name,
|
||||
tokenHash,
|
||||
scopes,
|
||||
policy,
|
||||
createdAt: now,
|
||||
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||
lastUsedAt: null,
|
||||
@@ -67,7 +73,7 @@ export class ApiTokenManager {
|
||||
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
|
||||
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
|
||||
|
||||
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const hash = this.hashToken(rawToken);
|
||||
|
||||
for (const stored of this.tokens.values()) {
|
||||
if (stored.tokenHash === hash) {
|
||||
@@ -87,7 +93,31 @@ export class ApiTokenManager {
|
||||
* Check if a token has a specific scope.
|
||||
*/
|
||||
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
||||
return token.scopes.includes(scope);
|
||||
if (token.policy?.role === 'admin') return true;
|
||||
|
||||
const isGatewayClientToken = token.policy?.role === 'gatewayClient';
|
||||
const gatewayClientAllowedScopes = new Set<TApiTokenScope>([
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
]);
|
||||
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isGatewayClientToken && token.scopes.includes('*')) return true;
|
||||
|
||||
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
|
||||
if (scopes.has(scope)) return true;
|
||||
|
||||
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
|
||||
'gateway-clients:read': ['workhosters:read'],
|
||||
'gateway-clients:write': ['workhosters:write'],
|
||||
'workhosters:read': ['gateway-clients:read'],
|
||||
'workhosters:write': ['gateway-clients:write'],
|
||||
};
|
||||
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +130,7 @@ export class ApiTokenManager {
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
scopes: stored.scopes,
|
||||
policy: stored.policy,
|
||||
createdAt: stored.createdAt,
|
||||
expiresAt: stored.expiresAt,
|
||||
lastUsedAt: stored.lastUsedAt,
|
||||
@@ -134,7 +165,7 @@ export class ApiTokenManager {
|
||||
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||
|
||||
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
stored.tokenHash = this.hashToken(rawToken);
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
|
||||
return { id, rawToken };
|
||||
@@ -165,6 +196,7 @@ export class ApiTokenManager {
|
||||
name: doc.name,
|
||||
tokenHash: doc.tokenHash,
|
||||
scopes: doc.scopes,
|
||||
policy: doc.policy,
|
||||
createdAt: doc.createdAt,
|
||||
expiresAt: doc.expiresAt,
|
||||
lastUsedAt: doc.lastUsedAt,
|
||||
@@ -175,12 +207,48 @@ export class ApiTokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureEnvAdminToken(): Promise<void> {
|
||||
const rawToken = process.env.DCROUTER_ADMIN_API_TOKEN?.trim();
|
||||
if (!rawToken) return;
|
||||
|
||||
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) {
|
||||
throw new Error(`DCROUTER_ADMIN_API_TOKEN must start with ${TOKEN_PREFIX_STR}`);
|
||||
}
|
||||
if (rawToken.length < TOKEN_PREFIX_STR.length + 32) {
|
||||
throw new Error('DCROUTER_ADMIN_API_TOKEN is too short');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existing = this.tokens.get(ENV_ADMIN_TOKEN_ID);
|
||||
const stored: IStoredApiToken = {
|
||||
id: ENV_ADMIN_TOKEN_ID,
|
||||
name: process.env.DCROUTER_ADMIN_API_TOKEN_NAME?.trim() || 'Environment Admin Token',
|
||||
tokenHash: this.hashToken(rawToken),
|
||||
scopes: ['*'],
|
||||
policy: { role: 'admin' },
|
||||
createdAt: existing?.createdAt || now,
|
||||
expiresAt: null,
|
||||
lastUsedAt: existing?.lastUsedAt || null,
|
||||
createdBy: existing?.createdBy || ENV_ADMIN_TOKEN_CREATED_BY,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
this.tokens.set(stored.id, stored);
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `Environment admin API token ensured (id: ${stored.id})`);
|
||||
}
|
||||
|
||||
private hashToken(rawToken: string): string {
|
||||
return plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
|
||||
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||
const existing = await ApiTokenDoc.findById(stored.id);
|
||||
if (existing) {
|
||||
existing.name = stored.name;
|
||||
existing.tokenHash = stored.tokenHash;
|
||||
existing.scopes = stored.scopes;
|
||||
existing.policy = stored.policy;
|
||||
existing.createdAt = stored.createdAt;
|
||||
existing.expiresAt = stored.expiresAt;
|
||||
existing.lastUsedAt = stored.lastUsedAt;
|
||||
@@ -193,6 +261,7 @@ export class ApiTokenManager {
|
||||
doc.name = stored.name;
|
||||
doc.tokenHash = stored.tokenHash;
|
||||
doc.scopes = stored.scopes;
|
||||
doc.policy = stored.policy;
|
||||
doc.createdAt = stored.createdAt;
|
||||
doc.expiresAt = stored.expiresAt;
|
||||
doc.lastUsedAt = stored.lastUsedAt;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { GatewayClientDoc } from '../db/index.js';
|
||||
import type { IGatewayClient } from '../../ts_interfaces/data/workhoster.js';
|
||||
|
||||
const defaultCapabilities: IGatewayClient['capabilities'] = {
|
||||
readDomains: true,
|
||||
readDnsRecords: true,
|
||||
syncRoutes: true,
|
||||
syncDnsRecords: false,
|
||||
requestCertificates: false,
|
||||
};
|
||||
|
||||
export class GatewayClientManager {
|
||||
public async initialize(): Promise<void> {}
|
||||
|
||||
public async listClients(): Promise<IGatewayClient[]> {
|
||||
const docs = await GatewayClientDoc.findAll();
|
||||
return docs.map((doc) => this.toPublicClient(doc));
|
||||
}
|
||||
|
||||
public async getClient(id: string): Promise<IGatewayClient | null> {
|
||||
const doc = await GatewayClientDoc.findById(id);
|
||||
return doc ? this.toPublicClient(doc) : null;
|
||||
}
|
||||
|
||||
public async createClient(options: {
|
||||
id?: string;
|
||||
type: IGatewayClient['type'];
|
||||
name: string;
|
||||
description?: string;
|
||||
hostnamePatterns?: string[];
|
||||
allowedRouteTargets?: IGatewayClient['allowedRouteTargets'];
|
||||
capabilities?: IGatewayClient['capabilities'];
|
||||
createdBy: string;
|
||||
}): Promise<IGatewayClient> {
|
||||
const id = this.normalizeId(options.id || `${options.type}-${plugins.uuid.v4()}`);
|
||||
if (!id) {
|
||||
throw new Error('gateway client id is required');
|
||||
}
|
||||
if (await GatewayClientDoc.findById(id)) {
|
||||
throw new Error('gateway client already exists');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const doc = new GatewayClientDoc();
|
||||
doc.id = id;
|
||||
doc.type = options.type;
|
||||
doc.name = options.name.trim();
|
||||
doc.description = options.description?.trim() || undefined;
|
||||
doc.hostnamePatterns = this.normalizeStringList(options.hostnamePatterns || []);
|
||||
doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(options.allowedRouteTargets || []);
|
||||
doc.capabilities = { ...defaultCapabilities, ...(options.capabilities || {}) };
|
||||
doc.enabled = true;
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
doc.createdBy = options.createdBy;
|
||||
await doc.save();
|
||||
return this.toPublicClient(doc);
|
||||
}
|
||||
|
||||
public async updateClient(
|
||||
id: string,
|
||||
patch: Partial<Pick<IGatewayClient, 'name' | 'description' | 'hostnamePatterns' | 'allowedRouteTargets' | 'capabilities' | 'enabled'>>,
|
||||
): Promise<IGatewayClient | null> {
|
||||
const doc = await GatewayClientDoc.findById(id);
|
||||
if (!doc) return null;
|
||||
if (patch.name !== undefined) doc.name = patch.name.trim();
|
||||
if (patch.description !== undefined) doc.description = patch.description.trim() || undefined;
|
||||
if (patch.hostnamePatterns !== undefined) doc.hostnamePatterns = this.normalizeStringList(patch.hostnamePatterns);
|
||||
if (patch.allowedRouteTargets !== undefined) doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(patch.allowedRouteTargets);
|
||||
if (patch.capabilities !== undefined) doc.capabilities = { ...defaultCapabilities, ...patch.capabilities };
|
||||
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
return this.toPublicClient(doc);
|
||||
}
|
||||
|
||||
public async deleteClient(id: string): Promise<boolean> {
|
||||
const doc = await GatewayClientDoc.findById(id);
|
||||
if (!doc) return false;
|
||||
await doc.delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
private normalizeId(id: string): string {
|
||||
return id.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
private normalizeStringList(values: string[]): string[] {
|
||||
return values.map((value) => value.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
private normalizeAllowedRouteTargets(targets: IGatewayClient['allowedRouteTargets']): IGatewayClient['allowedRouteTargets'] {
|
||||
return targets
|
||||
.map((target) => ({
|
||||
host: target.host.trim().toLowerCase(),
|
||||
ports: target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535),
|
||||
}))
|
||||
.filter((target) => target.host && target.ports.length > 0);
|
||||
}
|
||||
|
||||
private toPublicClient(doc: GatewayClientDoc): IGatewayClient {
|
||||
return {
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
hostnamePatterns: doc.hostnamePatterns || [],
|
||||
allowedRouteTargets: doc.allowedRouteTargets || [],
|
||||
capabilities: doc.capabilities || {},
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +255,15 @@ export class RouteConfigManager {
|
||||
return this.updateRoute(id, { enabled });
|
||||
}
|
||||
|
||||
public findApiRouteByExternalKey(externalKey: string): IRoute | undefined {
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.origin === 'api' && route.metadata?.externalKey === externalKey) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: seed routes from constructor config
|
||||
// =========================================================================
|
||||
@@ -443,6 +451,20 @@ export class RouteConfigManager {
|
||||
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
||||
? metadata.lastResolvedAt
|
||||
: undefined,
|
||||
ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
|
||||
? metadata.ownerType
|
||||
: undefined,
|
||||
gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
|
||||
? metadata.gatewayClientType
|
||||
: metadata.workHosterType,
|
||||
gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
|
||||
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
|
||||
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
|
||||
? metadata.workHosterType
|
||||
: metadata.gatewayClientType,
|
||||
workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
|
||||
workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
|
||||
externalKey: normalizeString(metadata.externalKey),
|
||||
};
|
||||
|
||||
if (!normalized.sourceProfileRef) {
|
||||
@@ -454,6 +476,20 @@ export class RouteConfigManager {
|
||||
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
||||
normalized.lastResolvedAt = undefined;
|
||||
}
|
||||
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
||||
normalized.gatewayClientType = undefined;
|
||||
normalized.gatewayClientId = undefined;
|
||||
normalized.gatewayClientAppId = undefined;
|
||||
normalized.workHosterType = undefined;
|
||||
normalized.workHosterId = undefined;
|
||||
normalized.workAppId = undefined;
|
||||
normalized.externalKey = undefined;
|
||||
} else {
|
||||
normalized.ownerType = 'gatewayClient';
|
||||
normalized.workHosterType = normalized.gatewayClientType;
|
||||
normalized.workHosterId = normalized.gatewayClientId;
|
||||
normalized.workAppId = normalized.gatewayClientAppId;
|
||||
}
|
||||
|
||||
if (Object.values(normalized).every((value) => value === undefined)) {
|
||||
return undefined;
|
||||
@@ -570,20 +606,48 @@ export class RouteConfigManager {
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
if (!vpnCallback) return route;
|
||||
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
|
||||
|
||||
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
|
||||
return route;
|
||||
}
|
||||
|
||||
const existingVpnSecurity = route.security?.vpn || {};
|
||||
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
|
||||
existingVpnSecurity.allowedClients || [],
|
||||
vpnEntries,
|
||||
);
|
||||
|
||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||
const existingEntries = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||
vpn: {
|
||||
...existingVpnSecurity,
|
||||
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
|
||||
allowedClients: mergedAllowedClients,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mergeVpnClientAllowEntries(
|
||||
existingEntries: TVpnClientAllowEntry[],
|
||||
vpnEntries: TVpnClientAllowEntry[],
|
||||
): TVpnClientAllowEntry[] {
|
||||
const merged: TVpnClientAllowEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of [...existingEntries, ...vpnEntries]) {
|
||||
const key = typeof entry === 'string'
|
||||
? `client:${entry}`
|
||||
: `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(entry);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
export * from './validator.js';
|
||||
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||
export { GatewayClientManager } from './classes.gateway-client-manager.js';
|
||||
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
export { DbSeeder } from './classes.db-seeder.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
||||
import type { IApiTokenPolicy, TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@@ -19,6 +19,9 @@ export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, A
|
||||
@plugins.smartdata.svDb()
|
||||
public scopes!: TApiTokenScope[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public policy?: IApiTokenPolicy;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IApiTokenPolicy, TGatewayClientType } from '../../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc<GatewayClientDoc, GatewayClientDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TGatewayClientType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public hostnamePatterns: string[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']> = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public capabilities: NonNullable<IApiTokenPolicy['capabilities']> = {};
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<GatewayClientDoc | null> {
|
||||
return await GatewayClientDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<GatewayClientDoc[]> {
|
||||
return await GatewayClientDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IIpIntelligenceRecord } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntelligenceDoc, IpIntelligenceDoc> implements IIpIntelligenceRecord {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public asnOrg: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public registrantOrg: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public registrantCountry: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public networkRange: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public networkCidrs: string[] | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public abuseContact: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public countryCode: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public city: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public latitude: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public longitude: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public accuracyRadius: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public timezone: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public firstSeenAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastSeenAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public seenCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByIp(ipAddress: string): Promise<IpIntelligenceDoc | null> {
|
||||
return await IpIntelligenceDoc.getInstance({ ipAddress });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<IpIntelligenceDoc[]> {
|
||||
return await IpIntelligenceDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityBlockRule, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityBlockRuleDoc extends plugins.smartdata.SmartDataDbDoc<SecurityBlockRuleDoc, SecurityBlockRuleDoc> implements ISecurityBlockRule {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TSecurityBlockRuleType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public matchMode?: TSecurityBlockRuleMatchMode;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public reason?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy: string = 'system';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SecurityBlockRuleDoc | null> {
|
||||
return await SecurityBlockRuleDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityPolicyAuditEvent } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityPolicyAuditDoc extends plugins.smartdata.SmartDataDbDoc<SecurityPolicyAuditDoc, SecurityPolicyAuditDoc> implements ISecurityPolicyAuditEvent {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public action!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public actor!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public details!: Record<string, unknown>;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findRecent(limit = 100): Promise<SecurityPolicyAuditDoc[]> {
|
||||
const docs = await SecurityPolicyAuditDoc.getInstances({});
|
||||
return docs.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
|
||||
@plugins.smartdata.svDb()
|
||||
public routeRefs?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public allowRoutesByClientSourceIp?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// Cached/TTL document classes
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
export * from './classes.ip-intelligence.doc.js';
|
||||
export * from './classes.security-block-rule.doc.js';
|
||||
export * from './classes.security-policy-audit.doc.js';
|
||||
|
||||
// Config document classes
|
||||
export * from './classes.route.doc.js';
|
||||
export * from './classes.api-token.doc.js';
|
||||
export * from './classes.gateway-client.doc.js';
|
||||
export * from './classes.source-profile.doc.js';
|
||||
export * from './classes.target-profile.doc.js';
|
||||
export * from './classes.network-target.doc.js';
|
||||
|
||||
+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}`);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,31 @@ export class EmailDomainManager {
|
||||
return doc ? this.docToInterface(doc) : null;
|
||||
}
|
||||
|
||||
public async getByDomain(domainName: string): Promise<IEmailDomain | null> {
|
||||
const doc = await EmailDomainDoc.findByDomain(domainName);
|
||||
return doc ? this.docToInterface(doc) : null;
|
||||
}
|
||||
|
||||
public async ensureEmailDomainForDomainName(domainName: string): Promise<IEmailDomain | null> {
|
||||
const normalizedDomain = domainName.trim().toLowerCase();
|
||||
const existing = await this.getByDomain(normalizedDomain);
|
||||
if (existing) return existing;
|
||||
if (this.isDomainAlreadyConfigured(normalizedDomain)) return null;
|
||||
|
||||
const linkedDomain = await this.findLinkedDnsDomain(normalizedDomain);
|
||||
if (!linkedDomain) {
|
||||
throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
|
||||
}
|
||||
|
||||
const subdomain = normalizedDomain === linkedDomain.name
|
||||
? undefined
|
||||
: normalizedDomain.slice(0, -(linkedDomain.name.length + 1));
|
||||
return await this.createEmailDomain({
|
||||
linkedDomainId: linkedDomain.id,
|
||||
subdomain,
|
||||
});
|
||||
}
|
||||
|
||||
public async createEmailDomain(opts: {
|
||||
linkedDomainId: string;
|
||||
subdomain?: string;
|
||||
@@ -351,6 +376,13 @@ export class EmailDomainManager {
|
||||
return configuredDomains.includes(domainName.toLowerCase());
|
||||
}
|
||||
|
||||
private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
|
||||
const domains = await DomainDoc.findAll();
|
||||
return domains
|
||||
.filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
|
||||
.sort((a, b) => b.name.length - a.name.length)[0] || null;
|
||||
}
|
||||
|
||||
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
const managedConfigs: IEmailDomainConfig[] = [];
|
||||
@@ -378,7 +410,7 @@ export class EmailDomainManager {
|
||||
return managedConfigs;
|
||||
}
|
||||
|
||||
private async syncManagedDomainsToRuntime(): Promise<void> {
|
||||
public async syncManagedDomainsToRuntime(): Promise<void> {
|
||||
if (!this.dcRouter.options?.emailConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import type {
|
||||
IEmailRoute,
|
||||
IUnifiedEmailServerOptions,
|
||||
} from '@push.rocks/smartmta';
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as interfaces from '../../ts_interfaces/index.js';
|
||||
|
||||
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
|
||||
|
||||
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
|
||||
smtpPassword: string;
|
||||
}
|
||||
|
||||
interface IStoredWorkAppMailState {
|
||||
version: 1;
|
||||
identities: IStoredWorkAppMailIdentity[];
|
||||
}
|
||||
|
||||
export class WorkAppMailManager {
|
||||
private readonly storageKey = '/workhosters/mail-identities.json';
|
||||
|
||||
constructor(private dcRouterRef: any) {}
|
||||
|
||||
public async listMailIdentities(
|
||||
ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
|
||||
): Promise<interfaces.data.IWorkAppMailIdentity[]> {
|
||||
const identities = await this.readStoredIdentities();
|
||||
return identities
|
||||
.filter((identity) => this.matchesOwnership(identity.ownership, ownership))
|
||||
.map((identity) => this.toPublicIdentity(identity));
|
||||
}
|
||||
|
||||
public async syncMailIdentity(
|
||||
request: TSyncRequest,
|
||||
createdBy: string,
|
||||
): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
|
||||
if (!this.dcRouterRef.options.emailConfig) {
|
||||
return { success: false, message: 'Email server is not configured' };
|
||||
}
|
||||
|
||||
const ownership = this.normalizeOwnership(request.ownership);
|
||||
const domain = this.normalizeDomain(request.domain);
|
||||
const localPart = this.normalizeLocalPart(request.localPart);
|
||||
const address = `${localPart}@${domain}`;
|
||||
const externalKey = this.buildExternalKey(ownership, address);
|
||||
const identities = await this.readStoredIdentities();
|
||||
const existingIndex = identities.findIndex((identity) => identity.externalKey === externalKey);
|
||||
|
||||
if (request.delete) {
|
||||
if (existingIndex < 0) {
|
||||
return { success: true, action: 'unchanged' };
|
||||
}
|
||||
const [deletedIdentity] = identities.splice(existingIndex, 1);
|
||||
await this.writeStoredIdentities(identities);
|
||||
await this.applyStoredIdentitiesToRuntime(identities);
|
||||
return {
|
||||
success: true,
|
||||
action: 'deleted',
|
||||
identity: this.toPublicIdentity(deletedIdentity),
|
||||
};
|
||||
}
|
||||
|
||||
await this.ensureEmailDomainConfigured(domain);
|
||||
|
||||
const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
|
||||
const now = Date.now();
|
||||
const smtpPassword = existingIdentity && !request.resetSmtpPassword
|
||||
? existingIdentity.smtpPassword
|
||||
: this.generateSmtpPassword();
|
||||
const identity: IStoredWorkAppMailIdentity = {
|
||||
id: existingIdentity?.id || plugins.smartunique.shortId(),
|
||||
externalKey,
|
||||
ownership,
|
||||
address,
|
||||
localPart,
|
||||
domain,
|
||||
enabled: request.enabled ?? existingIdentity?.enabled ?? true,
|
||||
displayName: request.displayName ?? existingIdentity?.displayName,
|
||||
inbound: this.normalizeInboundRoute(request.inbound ?? existingIdentity?.inbound),
|
||||
smtp: {
|
||||
enabled: request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true,
|
||||
username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
|
||||
},
|
||||
createdAt: existingIdentity?.createdAt || now,
|
||||
updatedAt: now,
|
||||
createdBy: existingIdentity?.createdBy || createdBy,
|
||||
smtpPassword,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
identities[existingIndex] = identity;
|
||||
} else {
|
||||
identities.push(identity);
|
||||
}
|
||||
|
||||
await this.writeStoredIdentities(identities);
|
||||
await this.applyStoredIdentitiesToRuntime(identities);
|
||||
|
||||
const response: interfaces.data.IWorkAppMailIdentitySyncResult = {
|
||||
success: true,
|
||||
action: existingIndex >= 0 ? 'updated' : 'created',
|
||||
identity: this.toPublicIdentity(identity),
|
||||
};
|
||||
|
||||
if (existingIndex < 0 || request.resetSmtpPassword) {
|
||||
response.smtpCredentials = this.buildSmtpCredentials(identity);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
||||
emailConfig: TConfig,
|
||||
): Promise<TConfig> {
|
||||
const identities = await this.readStoredIdentities();
|
||||
return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities);
|
||||
}
|
||||
|
||||
public async applyStoredIdentitiesToRuntime(
|
||||
identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
|
||||
): Promise<void> {
|
||||
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
||||
if (!emailConfig) return;
|
||||
|
||||
const nextConfig = this.mergeIdentitiesIntoEmailConfig(
|
||||
emailConfig,
|
||||
identities || await this.readStoredIdentities(),
|
||||
);
|
||||
|
||||
this.dcRouterRef.options.emailConfig = nextConfig;
|
||||
if (this.dcRouterRef.emailServer) {
|
||||
this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
|
||||
await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
|
||||
}
|
||||
}
|
||||
|
||||
private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
|
||||
const storedData = await this.dcRouterRef.storageManager.get(this.storageKey);
|
||||
if (!storedData) return [];
|
||||
const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
|
||||
return Array.isArray(parsed) ? parsed : parsed.identities || [];
|
||||
}
|
||||
|
||||
private async writeStoredIdentities(identities: IStoredWorkAppMailIdentity[]): Promise<void> {
|
||||
const state: IStoredWorkAppMailState = {
|
||||
version: 1,
|
||||
identities,
|
||||
};
|
||||
await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
||||
emailConfig: TConfig,
|
||||
identities: IStoredWorkAppMailIdentity[],
|
||||
): TConfig {
|
||||
const generatedRoutes = identities
|
||||
.filter((identity) => identity.enabled && identity.inbound?.enabled)
|
||||
.map((identity) => this.buildInboundRoute(identity));
|
||||
const configuredRoutes = (emailConfig.routes || [])
|
||||
.filter((route) => !this.isManagedMailRouteName(route.name));
|
||||
const generatedUsers = identities
|
||||
.filter((identity) => identity.enabled && identity.smtp.enabled)
|
||||
.map((identity) => ({
|
||||
username: identity.smtp.username,
|
||||
password: identity.smtpPassword,
|
||||
}));
|
||||
const configuredUsers = (emailConfig.auth?.users || [])
|
||||
.filter((user) => !this.isManagedSmtpUsername(user.username));
|
||||
|
||||
return {
|
||||
...emailConfig,
|
||||
routes: [...configuredRoutes, ...generatedRoutes],
|
||||
auth: {
|
||||
...(emailConfig.auth || {}),
|
||||
users: [...configuredUsers, ...generatedUsers],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
|
||||
const inbound = identity.inbound!;
|
||||
return {
|
||||
name: this.buildRouteName(identity.externalKey),
|
||||
priority: 1000,
|
||||
match: {
|
||||
recipients: identity.address,
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: inbound.targetHost,
|
||||
port: inbound.targetPort,
|
||||
preserveHeaders: inbound.preserveHeaders ?? true,
|
||||
addHeaders: {
|
||||
'X-Dcrouter-WorkHoster-Type': identity.ownership.workHosterType,
|
||||
'X-Dcrouter-WorkHoster-Id': identity.ownership.workHosterId,
|
||||
'X-Dcrouter-WorkApp-Id': identity.ownership.workAppId,
|
||||
...(inbound.addHeaders || {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureEmailDomainConfigured(domain: string): Promise<void> {
|
||||
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
||||
if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailDomainManager = this.dcRouterRef.emailDomainManager;
|
||||
if (!emailDomainManager) {
|
||||
throw new Error(`Email domain is not configured: ${domain}`);
|
||||
}
|
||||
|
||||
if (await emailDomainManager.getByDomain(domain)) {
|
||||
await emailDomainManager.syncManagedDomainsToRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
await emailDomainManager.ensureEmailDomainForDomainName(domain);
|
||||
}
|
||||
|
||||
private normalizeOwnership(
|
||||
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||
): interfaces.data.IWorkAppMailOwnership {
|
||||
const workHosterType = ownership.workHosterType;
|
||||
const workHosterId = ownership.workHosterId?.trim();
|
||||
const workAppId = ownership.workAppId?.trim();
|
||||
if (!['onebox', 'cloudly', 'custom'].includes(workHosterType)) {
|
||||
throw new Error(`Invalid WorkHoster type: ${workHosterType}`);
|
||||
}
|
||||
if (!workHosterId) throw new Error('workHosterId is required');
|
||||
if (!workAppId) throw new Error('workAppId is required');
|
||||
return { workHosterType, workHosterId, workAppId };
|
||||
}
|
||||
|
||||
private normalizeDomain(domain: string): string {
|
||||
const normalized = domain?.trim().toLowerCase();
|
||||
if (!normalized || normalized.includes('@') || !normalized.includes('.')) {
|
||||
throw new Error(`Invalid email domain: ${domain}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeLocalPart(localPart: string): string {
|
||||
const normalized = localPart?.trim().toLowerCase();
|
||||
if (!normalized || normalized.includes('@') || /\s/.test(normalized)) {
|
||||
throw new Error(`Invalid email local part: ${localPart}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeInboundRoute(
|
||||
inbound?: interfaces.data.IWorkAppMailInboundRoute,
|
||||
): interfaces.data.IWorkAppMailInboundRoute | undefined {
|
||||
if (!inbound) return undefined;
|
||||
if (!inbound.enabled) {
|
||||
return { ...inbound, enabled: false };
|
||||
}
|
||||
const targetHost = inbound.targetHost?.trim();
|
||||
const targetPort = Number(inbound.targetPort);
|
||||
if (!targetHost) throw new Error('inbound.targetHost is required when inbound routing is enabled');
|
||||
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
|
||||
throw new Error(`Invalid inbound.targetPort: ${inbound.targetPort}`);
|
||||
}
|
||||
return {
|
||||
...inbound,
|
||||
targetHost,
|
||||
targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
private matchesOwnership(
|
||||
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||
filter?: Partial<interfaces.data.IWorkAppMailOwnership>,
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.workHosterType && filter.workHosterType !== ownership.workHosterType) return false;
|
||||
if (filter.workHosterId && filter.workHosterId !== ownership.workHosterId) return false;
|
||||
if (filter.workAppId && filter.workAppId !== ownership.workAppId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private buildExternalKey(
|
||||
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||
address: string,
|
||||
): string {
|
||||
return [
|
||||
ownership.workHosterType,
|
||||
ownership.workHosterId,
|
||||
ownership.workAppId,
|
||||
address,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
private buildSmtpUsername(externalKey: string): string {
|
||||
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
|
||||
}
|
||||
|
||||
private buildRouteName(externalKey: string): string {
|
||||
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
|
||||
}
|
||||
|
||||
private hashExternalKey(externalKey: string): string {
|
||||
return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
|
||||
}
|
||||
|
||||
private generateSmtpPassword(): string {
|
||||
return plugins.crypto.randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
private isManagedMailRouteName(routeName: string): boolean {
|
||||
return routeName.startsWith('workapp-mail-');
|
||||
}
|
||||
|
||||
private isManagedSmtpUsername(username: string): boolean {
|
||||
return username.startsWith('workapp-');
|
||||
}
|
||||
|
||||
private buildSmtpCredentials(
|
||||
identity: IStoredWorkAppMailIdentity,
|
||||
): interfaces.data.IWorkAppMailCredentials {
|
||||
return {
|
||||
username: identity.smtp.username,
|
||||
password: identity.smtpPassword,
|
||||
host: this.dcRouterRef.options.emailConfig?.outbound?.hostname
|
||||
|| this.dcRouterRef.options.emailConfig?.hostname,
|
||||
ports: {
|
||||
smtp: this.dcRouterRef.options.emailConfig?.ports?.includes(25) ? 25 : undefined,
|
||||
submission: this.dcRouterRef.options.emailConfig?.ports?.includes(587) ? 587 : undefined,
|
||||
smtps: this.dcRouterRef.options.emailConfig?.ports?.includes(465) ? 465 : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toPublicIdentity(
|
||||
identity: IStoredWorkAppMailIdentity,
|
||||
): interfaces.data.IWorkAppMailIdentity {
|
||||
const { smtpPassword, ...publicIdentity } = identity;
|
||||
return publicIdentity;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
export * from './classes.smartmta-storage-manager.js';
|
||||
export * from './classes.workapp-mail-manager.js';
|
||||
export * from './email-dns-records.js';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DcRouter } from '../classes.dcrouter.js';
|
||||
import { MetricsCache } from './classes.metricscache.js';
|
||||
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { IAsnActivity } from '../../ts_interfaces/data/stats.js';
|
||||
|
||||
export class MetricsManager {
|
||||
private metricsLogger: plugins.smartlog.Smartlog;
|
||||
@@ -545,7 +546,7 @@ export class MetricsManager {
|
||||
// Get network metrics from SmartProxy
|
||||
public async getNetworkStats() {
|
||||
// Use shorter cache TTL for network stats to ensure real-time updates
|
||||
return this.metricsCache.get('networkStats', () => {
|
||||
return this.metricsCache.get('networkStats', async () => {
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
|
||||
if (!proxyMetrics) {
|
||||
@@ -554,6 +555,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 }>(),
|
||||
@@ -725,6 +727,16 @@ export class MetricsManager {
|
||||
.slice(0, 10)
|
||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||
|
||||
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();
|
||||
@@ -867,6 +879,7 @@ export class MetricsManager {
|
||||
throughputRate,
|
||||
topIPs,
|
||||
topIPsByBandwidth,
|
||||
topASNs,
|
||||
totalDataTransferred,
|
||||
throughputHistory,
|
||||
throughputByIP,
|
||||
@@ -880,6 +893,60 @@ export class MetricsManager {
|
||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||
}
|
||||
|
||||
private async buildTopASNs(
|
||||
observedIps: string[],
|
||||
allIPData: Map<string, { count: number; bwIn: number; bwOut: number }>,
|
||||
): Promise<IAsnActivity[]> {
|
||||
const manager = this.dcRouter.securityPolicyManager;
|
||||
if (!manager || observedIps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const intelligenceRecords = await manager.listIpIntelligence({
|
||||
ipAddresses: observedIps,
|
||||
limit: Math.max(100, observedIps.length),
|
||||
});
|
||||
const asnActivity = new Map<number, IAsnActivity>();
|
||||
|
||||
for (const record of intelligenceRecords) {
|
||||
if (typeof record.asn !== 'number') continue;
|
||||
|
||||
const ipData = allIPData.get(record.ipAddress);
|
||||
if (!ipData) continue;
|
||||
|
||||
const existing = asnActivity.get(record.asn);
|
||||
const activity = existing || {
|
||||
asn: record.asn,
|
||||
organization: record.asnOrg || record.registrantOrg || `AS${record.asn}`,
|
||||
country: record.countryCode || record.country || record.registrantCountry || null,
|
||||
activeConnections: 0,
|
||||
ipCount: 0,
|
||||
bytesInPerSecond: 0,
|
||||
bytesOutPerSecond: 0,
|
||||
sampleIps: [],
|
||||
};
|
||||
|
||||
activity.activeConnections += ipData.count;
|
||||
activity.bytesInPerSecond += ipData.bwIn;
|
||||
activity.bytesOutPerSecond += ipData.bwOut;
|
||||
activity.ipCount++;
|
||||
if (activity.sampleIps.length < 5) {
|
||||
activity.sampleIps.push(record.ipAddress);
|
||||
}
|
||||
asnActivity.set(record.asn, activity);
|
||||
}
|
||||
|
||||
return [...asnActivity.values()]
|
||||
.sort((a, b) => {
|
||||
const connectionDiff = b.activeConnections - a.activeConnections;
|
||||
if (connectionDiff !== 0) return connectionDiff;
|
||||
const bandwidthA = a.bytesInPerSecond + a.bytesOutPerSecond;
|
||||
const bandwidthB = b.bytesInPerSecond + b.bytesOutPerSecond;
|
||||
return bandwidthB - bandwidthA;
|
||||
})
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
// --- Time-series helpers ---
|
||||
|
||||
private static minuteKey(ts: number = Date.now()): number {
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import * as handlers from './handlers/index.js';
|
||||
import * as interfaces from '../../ts_interfaces/index.js';
|
||||
import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js';
|
||||
|
||||
export class OpsServer {
|
||||
public dcRouterRef: DcRouter;
|
||||
@@ -12,9 +11,9 @@ export class OpsServer {
|
||||
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Auth-enforced routers — middleware validates identity before any handler runs
|
||||
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||
// Grouped routers. Handlers enforce auth explicitly with per-endpoint scopes.
|
||||
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity?: interfaces.data.IIdentity; apiToken?: string } }>();
|
||||
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity?: interfaces.data.IIdentity; apiToken?: string } }>();
|
||||
|
||||
// Handler instances
|
||||
public adminHandler!: handlers.AdminHandler;
|
||||
@@ -38,6 +37,7 @@ export class OpsServer {
|
||||
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||
private emailDomainHandler!: handlers.EmailDomainHandler;
|
||||
private workHosterHandler!: handlers.WorkHosterHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -71,16 +71,6 @@ export class OpsServer {
|
||||
this.adminHandler = new handlers.AdminHandler(this);
|
||||
await this.adminHandler.initialize();
|
||||
|
||||
// viewRouter middleware: requires valid identity (any logged-in user)
|
||||
this.viewRouter.addMiddleware(async (typedRequest) => {
|
||||
await requireValidIdentity(this.adminHandler, typedRequest.request);
|
||||
});
|
||||
|
||||
// adminRouter middleware: requires admin identity
|
||||
this.adminRouter.addMiddleware(async (typedRequest) => {
|
||||
await requireAdminIdentity(this.adminHandler, typedRequest.request);
|
||||
});
|
||||
|
||||
// Connect auth routers to the main typedrouter
|
||||
this.typedrouter.addTypedRouter(this.viewRouter);
|
||||
this.typedrouter.addTypedRouter(this.adminRouter);
|
||||
@@ -106,11 +96,15 @@ export class OpsServer {
|
||||
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
||||
this.workHosterHandler = new handlers.WorkHosterHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.adminHandler) {
|
||||
await this.adminHandler.stop();
|
||||
}
|
||||
// Clean up log handler streams and push destination before stopping the server
|
||||
if (this.logsHandler) {
|
||||
this.logsHandler.cleanup();
|
||||
@@ -119,4 +113,4 @@ export class OpsServer {
|
||||
await this.server.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,19 +8,34 @@ export interface IJwtData {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type TAdminUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
status?: 'active' | 'disabled';
|
||||
authSources?: Array<'local' | 'idp.global'>;
|
||||
};
|
||||
|
||||
export class AdminHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// JWT instance
|
||||
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
|
||||
// Simple in-memory user storage (in production, use proper database)
|
||||
// Ephemeral bootstrap users. DB-backed instances may use these only until the
|
||||
// database is ready and the first persistent admin account has been created.
|
||||
private users = new Map<string, {
|
||||
id: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}>();
|
||||
|
||||
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
|
||||
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
|
||||
private ownsIdpClient = false;
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
@@ -32,6 +47,14 @@ export class AdminHandler {
|
||||
this.initializeDefaultUsers();
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (this.ownsIdpClient) {
|
||||
await this.idpClient?.stop();
|
||||
}
|
||||
this.idpClient = undefined;
|
||||
this.ownsIdpClient = false;
|
||||
}
|
||||
|
||||
private async initializeJwt(): Promise<void> {
|
||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||
@@ -43,65 +66,232 @@ export class AdminHandler {
|
||||
}
|
||||
|
||||
private initializeDefaultUsers(): void {
|
||||
// Add default admin user
|
||||
const username = process.env.DCROUTER_ADMIN_USERNAME || 'admin';
|
||||
const configuredPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
const password = configuredPassword || plugins.crypto.randomBytes(24).toString('base64url');
|
||||
|
||||
const adminId = plugins.uuid.v4();
|
||||
this.users.set(adminId, {
|
||||
id: adminId,
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
username,
|
||||
password,
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
if (!configuredPassword) {
|
||||
console.warn(`DCRouter generated one-time admin password for ${username}: ${password}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a safe projection of the users Map — excludes password fields.
|
||||
* Return a safe projection of the active user source — excludes password fields.
|
||||
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
||||
*/
|
||||
public listUsers(): Array<{ id: string; username: string; role: string }> {
|
||||
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
||||
const accountState = await this.getPersistentAccountState();
|
||||
if (accountState.dbEnabled && !accountState.dbReady) {
|
||||
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
||||
}
|
||||
if (accountState.hasPersistentAdmin) {
|
||||
const accounts = await accountState.store!.listAccounts();
|
||||
return accounts.map((accountArg) => this.accountToUser(accountArg));
|
||||
}
|
||||
|
||||
return Array.from(this.users.values()).map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
|
||||
const accountState = await this.getPersistentAccountState();
|
||||
const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
|
||||
return {
|
||||
dbEnabled: accountState.dbEnabled,
|
||||
dbReady: accountState.dbReady,
|
||||
hasPersistentAdmin: accountState.hasPersistentAdmin,
|
||||
needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
|
||||
ephemeralAdminAvailable: bootstrapAvailable,
|
||||
idpGlobalConfigured: this.isIdpGlobalConfigured(),
|
||||
};
|
||||
}
|
||||
|
||||
public async createInitialAdminUser(optionsArg: {
|
||||
email: string;
|
||||
name?: string;
|
||||
password: string;
|
||||
enableIdpGlobalAuth?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_CreateInitialAdminUser['response']> {
|
||||
const store = this.getAccountStore();
|
||||
if (!store) {
|
||||
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
||||
}
|
||||
|
||||
if (await store.hasActiveAdminAccount()) {
|
||||
throw new plugins.typedrequest.TypedResponseError('initial admin already exists');
|
||||
}
|
||||
|
||||
const password = String(optionsArg.password || '');
|
||||
if (!password) {
|
||||
throw new plugins.typedrequest.TypedResponseError('password is required');
|
||||
}
|
||||
|
||||
const email = String(optionsArg.email || '').trim();
|
||||
const authSources: Array<'local' | 'idp.global'> = ['local'];
|
||||
if (optionsArg.enableIdpGlobalAuth) {
|
||||
authSources.push('idp.global');
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await store.createAccount({
|
||||
email,
|
||||
name: String(optionsArg.name || '').trim() || email,
|
||||
role: 'admin',
|
||||
authSources,
|
||||
password,
|
||||
});
|
||||
const user = this.accountToUser(account);
|
||||
return {
|
||||
success: true,
|
||||
identity: await this.createIdentityForUser(user),
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
|
||||
}
|
||||
}
|
||||
|
||||
public async createUser(optionsArg: {
|
||||
email: string;
|
||||
name?: string;
|
||||
role: interfaces.requests.TUserManagementRole;
|
||||
password: string;
|
||||
enableIdpGlobalAuth?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_CreateUser['response']> {
|
||||
const store = this.getAccountStore();
|
||||
if (!store) {
|
||||
return { success: false, message: 'database is not ready' };
|
||||
}
|
||||
if (!(await store.hasActiveAdminAccount())) {
|
||||
return { success: false, message: 'initial admin bootstrap is required before creating users' };
|
||||
}
|
||||
|
||||
const role = optionsArg.role;
|
||||
if (role !== 'admin' && role !== 'user') {
|
||||
return { success: false, message: 'role must be admin or user' };
|
||||
}
|
||||
|
||||
const password = String(optionsArg.password || '');
|
||||
if (!password) {
|
||||
return { success: false, message: 'password is required' };
|
||||
}
|
||||
|
||||
const authSources: Array<'local' | 'idp.global'> = ['local'];
|
||||
if (optionsArg.enableIdpGlobalAuth) {
|
||||
authSources.push('idp.global');
|
||||
}
|
||||
|
||||
try {
|
||||
const email = String(optionsArg.email || '').trim();
|
||||
const account = await store.createAccount({
|
||||
email,
|
||||
name: String(optionsArg.name || '').trim() || email,
|
||||
role,
|
||||
authSources,
|
||||
password,
|
||||
});
|
||||
return { success: true, user: this.accountToUser(account) };
|
||||
} catch (error) {
|
||||
return { success: false, message: (error as Error).message || 'failed to create user' };
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteUser(optionsArg: {
|
||||
id: string;
|
||||
requestingUserId: string;
|
||||
}): Promise<interfaces.requests.IReq_DeleteUser['response']> {
|
||||
const store = this.getAccountStore();
|
||||
if (!store) {
|
||||
return { success: false, message: 'database is not ready' };
|
||||
}
|
||||
if (!(await store.hasActiveAdminAccount())) {
|
||||
return { success: false, message: 'initial admin bootstrap is required before deleting users' };
|
||||
}
|
||||
|
||||
const id = String(optionsArg.id || '').trim();
|
||||
if (!id) {
|
||||
return { success: false, message: 'user id is required' };
|
||||
}
|
||||
if (id === optionsArg.requestingUserId) {
|
||||
return { success: false, message: 'cannot delete the current user' };
|
||||
}
|
||||
|
||||
const account = await store.getAccountById(id);
|
||||
if (!account) {
|
||||
return { success: false, message: 'user not found' };
|
||||
}
|
||||
|
||||
if (account.role === 'admin' && account.status === 'active') {
|
||||
const activeAdmins = (await store.listAccounts()).filter(
|
||||
(accountArg) => accountArg.role === 'admin' && accountArg.status === 'active',
|
||||
);
|
||||
if (activeAdmins.length <= 1) {
|
||||
return { success: false, message: 'cannot delete the last active admin' };
|
||||
}
|
||||
}
|
||||
|
||||
const doc = await plugins.idpSdkServer.IdpSdkAccountDoc.findById(id);
|
||||
if (!doc) {
|
||||
return { success: false, message: 'user not found' };
|
||||
}
|
||||
await doc.delete();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||
'getAdminBootstrapStatus',
|
||||
async (_dataArg) => this.getBootstrapStatus()
|
||||
)
|
||||
);
|
||||
|
||||
this.opsServerRef.adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||
'createInitialAdminUser',
|
||||
async (dataArg) => {
|
||||
const isAdmin = await this.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!isAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('admin identity required');
|
||||
}
|
||||
return this.createInitialAdminUser({
|
||||
email: dataArg.email,
|
||||
name: dataArg.name,
|
||||
password: dataArg.password,
|
||||
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Admin Login Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
// Find user by username and password
|
||||
let user: { id: string; username: string; password: string; role: string } | null = null;
|
||||
for (const [_, userData] of this.users) {
|
||||
if (userData.username === dataArg.username && userData.password === dataArg.password) {
|
||||
user = userData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.authenticateUser({
|
||||
username: dataArg.username,
|
||||
password: dataArg.password,
|
||||
authSource: dataArg.authSource,
|
||||
});
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||
}
|
||||
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId: user.id,
|
||||
status: 'loggedIn',
|
||||
expiresAt: expiresAtTimestamp,
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: expiresAtTimestamp,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
identity: await this.createIdentityForUser(user),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||
@@ -118,8 +308,10 @@ export class AdminHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
|
||||
'adminLogout',
|
||||
async (dataArg) => {
|
||||
// In a real implementation, you might want to blacklist the JWT
|
||||
// For now, just return success
|
||||
const identity = await this.validateIdentity(dataArg.identity);
|
||||
if (!identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('identity is not valid');
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
@@ -132,53 +324,8 @@ export class AdminHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'verifyIdentity',
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
|
||||
// Check if expired
|
||||
if (jwtData.expiresAt < Date.now()) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if logged in
|
||||
if (jwtData.status !== 'loggedIn') {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = this.users.get(jwtData.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
const identity = await this.validateIdentity(dataArg.identity);
|
||||
return identity ? { valid: true, identity } : { valid: false };
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -191,36 +338,7 @@ export class AdminHandler {
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
|
||||
// Check expiration
|
||||
if (jwtData.expiresAt < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check status
|
||||
if (jwtData.status !== 'loggedIn') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify data hasn't been tampered with
|
||||
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataArg.identity.userId !== jwtData.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(await this.validateIdentity(dataArg.identity));
|
||||
},
|
||||
{
|
||||
failedHint: 'identity is not valid',
|
||||
@@ -235,18 +353,186 @@ export class AdminHandler {
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
// First check if identity is valid
|
||||
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
return dataArg.identity.role === 'admin';
|
||||
const identity = await this.validateIdentity(dataArg.identity);
|
||||
return identity?.role === 'admin';
|
||||
},
|
||||
{
|
||||
failedHint: 'user is not admin',
|
||||
name: 'adminIdentityGuard',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async validateIdentity(
|
||||
identityArg?: interfaces.data.IIdentity,
|
||||
): Promise<interfaces.data.IIdentity | null> {
|
||||
if (!identityArg?.jwt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(identityArg.jwt);
|
||||
if (jwtData.expiresAt < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
if (jwtData.status !== 'loggedIn') {
|
||||
return null;
|
||||
}
|
||||
if (identityArg.expiresAt !== jwtData.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
if (identityArg.userId !== jwtData.userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.resolveUser(jwtData.userId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
if (identityArg.role && identityArg.role !== user.role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
jwt: identityArg.jwt,
|
||||
userId: user.id,
|
||||
name: user.name || user.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateUser(optionsArg: {
|
||||
username: string;
|
||||
password: string;
|
||||
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
||||
}): Promise<TAdminUser | null> {
|
||||
const accountState = await this.getPersistentAccountState();
|
||||
if (accountState.dbEnabled && !accountState.dbReady) {
|
||||
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
||||
}
|
||||
|
||||
if (accountState.hasPersistentAdmin) {
|
||||
const authService = new plugins.idpSdkServer.AccountAuthService({
|
||||
store: accountState.store!,
|
||||
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
|
||||
});
|
||||
const result = await authService.authenticate({
|
||||
email: optionsArg.username,
|
||||
password: optionsArg.password,
|
||||
authSource: optionsArg.authSource || 'auto',
|
||||
});
|
||||
return result ? this.accountToUser(result.account) : null;
|
||||
}
|
||||
|
||||
for (const [_, userData] of this.users) {
|
||||
if (userData.username === optionsArg.username && userData.password === optionsArg.password) {
|
||||
return userData;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
|
||||
const accountState = await this.getPersistentAccountState();
|
||||
if (accountState.dbEnabled && !accountState.dbReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (accountState.hasPersistentAdmin) {
|
||||
const account = await accountState.store!.getAccountById(userIdArg);
|
||||
if (!account || account.status !== 'active') {
|
||||
return null;
|
||||
}
|
||||
return this.accountToUser(account);
|
||||
}
|
||||
|
||||
return this.users.get(userIdArg) || null;
|
||||
}
|
||||
|
||||
private async getPersistentAccountState(): Promise<{
|
||||
dbEnabled: boolean;
|
||||
dbReady: boolean;
|
||||
store: plugins.idpSdkServer.SmartdataAccountStore | null;
|
||||
hasPersistentAdmin: boolean;
|
||||
}> {
|
||||
const dbEnabled = this.isPersistenceEnabled();
|
||||
const store = dbEnabled ? this.getAccountStore() : null;
|
||||
const dbReady = !!store;
|
||||
const hasPersistentAdmin = store ? await store.hasActiveAdminAccount() : false;
|
||||
return { dbEnabled, dbReady, store, hasPersistentAdmin };
|
||||
}
|
||||
|
||||
private isPersistenceEnabled(): boolean {
|
||||
return this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
|
||||
}
|
||||
|
||||
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
|
||||
if (!this.isPersistenceEnabled()) {
|
||||
return null;
|
||||
}
|
||||
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
|
||||
if (!dcRouterDb?.isReady()) {
|
||||
return null;
|
||||
}
|
||||
if (!this.accountStore) {
|
||||
this.accountStore = new plugins.idpSdkServer.SmartdataAccountStore({
|
||||
smartdataDb: dcRouterDb.getDb(),
|
||||
});
|
||||
}
|
||||
return this.accountStore;
|
||||
}
|
||||
|
||||
private getIdpClient(): Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'> | undefined {
|
||||
const configuredClient = this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient;
|
||||
if (configuredClient) {
|
||||
return configuredClient;
|
||||
}
|
||||
|
||||
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
|
||||
if (!this.idpClient) {
|
||||
this.idpClient = baseUrl
|
||||
? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl })
|
||||
: new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions);
|
||||
this.ownsIdpClient = true;
|
||||
}
|
||||
return this.idpClient;
|
||||
}
|
||||
|
||||
private isIdpGlobalConfigured(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
|
||||
return {
|
||||
id: accountArg.id,
|
||||
username: accountArg.email,
|
||||
email: accountArg.email,
|
||||
name: accountArg.name,
|
||||
role: accountArg.role,
|
||||
status: accountArg.status,
|
||||
authSources: accountArg.authSources,
|
||||
};
|
||||
}
|
||||
|
||||
private async createIdentityForUser(userArg: TAdminUser): Promise<interfaces.data.IIdentity> {
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId: userArg.id,
|
||||
status: 'loggedIn',
|
||||
expiresAt: expiresAtTimestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
jwt,
|
||||
userId: userArg.id,
|
||||
name: userArg.name || userArg.username,
|
||||
expiresAt: expiresAtTimestamp,
|
||||
role: userArg.role,
|
||||
type: 'user',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
export class ApiTokenHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -17,6 +18,11 @@ export class ApiTokenHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
|
||||
'createApiToken',
|
||||
async (dataArg) => {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'tokens:manage',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
@@ -25,7 +31,8 @@ export class ApiTokenHandler {
|
||||
dataArg.name,
|
||||
dataArg.scopes,
|
||||
dataArg.expiresInDays ?? null,
|
||||
dataArg.identity.userId,
|
||||
auth.userId,
|
||||
dataArg.policy,
|
||||
);
|
||||
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||
},
|
||||
@@ -37,6 +44,11 @@ export class ApiTokenHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
|
||||
'listApiTokens',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'tokens:read',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { tokens: [] };
|
||||
@@ -51,6 +63,11 @@ export class ApiTokenHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
|
||||
'revokeApiToken',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'tokens:manage',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
@@ -66,6 +83,11 @@ export class ApiTokenHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
|
||||
'rollApiToken',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'tokens:manage',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
@@ -84,6 +106,11 @@ export class ApiTokenHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
|
||||
'toggleApiToken',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'tokens:manage',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
/**
|
||||
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
|
||||
@@ -26,21 +27,33 @@ export function deriveCertDomainName(domain: string): string | undefined {
|
||||
}
|
||||
|
||||
export class CertificateHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter?.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
const viewRouter = this.opsServerRef.viewRouter;
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, request, {
|
||||
scope: requiredScope,
|
||||
requireAdminIdentity: requiredScope?.endsWith(':write'),
|
||||
});
|
||||
return auth.userId;
|
||||
}
|
||||
|
||||
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||
private registerHandlers(): void {
|
||||
const router = this.typedrouter;
|
||||
|
||||
// Get Certificate Overview
|
||||
viewRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
||||
'getCertificateOverview',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:read');
|
||||
const certificates = await this.buildCertificateOverview();
|
||||
const summary = this.buildSummary(certificates);
|
||||
return { certificates, summary };
|
||||
@@ -48,53 +61,56 @@ export class CertificateHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||
|
||||
// Legacy route-based reprovision (backward compat)
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||
'reprovisionCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.reprovisionCertificateByRoute(dataArg.routeName);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Domain-based reprovision (preferred)
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
'reprovisionCertificateDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Delete certificate
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||
'deleteCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.deleteCertificate(dataArg.domain);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Export certificate
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||
'exportCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:read');
|
||||
return this.exportCertificate(dataArg.domain);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Import certificate
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||
'importCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.importCertificate(dataArg.cert);
|
||||
}
|
||||
)
|
||||
@@ -274,6 +290,11 @@ export class CertificateHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (backoffInfo && status !== 'valid' && status !== 'expiring') {
|
||||
status = 'failed';
|
||||
error = error || backoffInfo.lastError;
|
||||
}
|
||||
|
||||
certificates.push({
|
||||
domain,
|
||||
routeNames: info.routeNames,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -18,4 +18,5 @@ export * from './dns-provider.handler.js';
|
||||
export * from './domain.handler.js';
|
||||
export * from './dns-record.handler.js';
|
||||
export * from './acme-config.handler.js';
|
||||
export * from './email-domain.handler.js';
|
||||
export * from './email-domain.handler.js';
|
||||
export * from './workhoster.handler.js';
|
||||
|
||||
@@ -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,6 +48,10 @@ export class RemoteIngressHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
|
||||
'createRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'remote-ingress:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
|
||||
@@ -78,6 +84,10 @@ export class RemoteIngressHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
|
||||
'deleteRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'remote-ingress:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
|
||||
@@ -103,6 +113,10 @@ export class RemoteIngressHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
|
||||
'updateRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'remote-ingress:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
|
||||
@@ -148,6 +162,10 @@ export class RemoteIngressHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
||||
'regenerateRemoteIngressSecret',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'remote-ingress:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
|
||||
@@ -175,6 +193,7 @@ export class RemoteIngressHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
||||
'getRemoteIngressStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
if (!tunnelManager) {
|
||||
return { statuses: [] };
|
||||
@@ -189,6 +208,10 @@ export class RemoteIngressHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
||||
'getRemoteIngressConnectionToken',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'remote-ingress:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'RemoteIngress not configured' };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
export class RouteManagementHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -18,31 +19,11 @@ export class RouteManagementHandler {
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
// Try JWT identity first
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// Try API token
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
const auth = await requireOpsAuth(this.opsServerRef, request, {
|
||||
scope: requiredScope,
|
||||
requireAdminIdentity: requiredScope?.endsWith(':write'),
|
||||
});
|
||||
return auth.userId;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
export class SecurityHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -17,6 +18,7 @@ export class SecurityHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||
'getSecurityMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
||||
const metrics = await this.collectSecurityMetrics();
|
||||
return {
|
||||
metrics: {
|
||||
@@ -43,6 +45,7 @@ export class SecurityHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
||||
'getActiveConnections',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
|
||||
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
|
||||
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
|
||||
id: conn.id,
|
||||
@@ -82,6 +85,7 @@ export class SecurityHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
|
||||
'getNetworkStats',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
|
||||
// Get network stats from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
@@ -99,6 +103,7 @@ export class SecurityHandler {
|
||||
throughputRate: networkStats.throughputRate,
|
||||
topIPs: networkStats.topIPs,
|
||||
topIPsByBandwidth: networkStats.topIPsByBandwidth,
|
||||
topASNs: networkStats.topASNs,
|
||||
totalDataTransferred: networkStats.totalDataTransferred,
|
||||
throughputHistory: networkStats.throughputHistory || [],
|
||||
throughputByIP,
|
||||
@@ -117,6 +122,7 @@ export class SecurityHandler {
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
topIPsByBandwidth: [],
|
||||
topASNs: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [],
|
||||
throughputByIP: [],
|
||||
@@ -136,6 +142,7 @@ export class SecurityHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||
'getRateLimitStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
||||
const status = await this.getRateLimitStatus(dataArg.domain, dataArg.ip);
|
||||
const limits: interfaces.data.IRateLimitInfo[] = status.limits.map(limit => ({
|
||||
domain: limit.identifier,
|
||||
@@ -157,6 +164,140 @@ export class SecurityHandler {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityBlockRules>(
|
||||
'listSecurityBlockRules',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
return { rules: manager ? await manager.listBlockRules() : [] };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListIpIntelligence>(
|
||||
'listIpIntelligence',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
return {
|
||||
records: manager
|
||||
? await manager.listIpIntelligence({
|
||||
ipAddresses: dataArg.ipAddresses,
|
||||
limit: dataArg.limit,
|
||||
})
|
||||
: [],
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCompiledSecurityPolicy>(
|
||||
'getCompiledSecurityPolicy',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
return {
|
||||
policy: manager
|
||||
? await manager.compilePolicy()
|
||||
: { blockedIps: [], blockedCidrs: [] },
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityPolicyAudit>(
|
||||
'listSecurityPolicyAudit',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
return { events: manager ? await manager.listAuditEvents(dataArg.limit || 100) : [] };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityBlockRule>(
|
||||
'createSecurityBlockRule',
|
||||
async (dataArg) => {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'security:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
|
||||
const rule = await manager.createBlockRule({
|
||||
type: dataArg.type,
|
||||
value: dataArg.value,
|
||||
matchMode: dataArg.matchMode,
|
||||
reason: dataArg.reason,
|
||||
enabled: dataArg.enabled,
|
||||
}, auth.userId);
|
||||
return { success: true, rule };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityBlockRule>(
|
||||
'updateSecurityBlockRule',
|
||||
async (dataArg) => {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'security:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
|
||||
const rule = await manager.updateBlockRule(dataArg.id, {
|
||||
value: dataArg.value,
|
||||
matchMode: dataArg.matchMode,
|
||||
reason: dataArg.reason,
|
||||
enabled: dataArg.enabled,
|
||||
}, auth.userId);
|
||||
return rule ? { success: true, rule } : { success: false, message: 'Rule not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityBlockRule>(
|
||||
'deleteSecurityBlockRule',
|
||||
async (dataArg) => {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'security:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
|
||||
const success = await manager.deleteBlockRule(dataArg.id, auth.userId);
|
||||
return { success, message: success ? undefined : 'Rule not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshIpIntelligence>(
|
||||
'refreshIpIntelligence',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'security:write',
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
|
||||
const record = await manager.refreshIpIntelligence(dataArg.ipAddress);
|
||||
return record
|
||||
? { success: true, record }
|
||||
: { success: false, message: 'IP address is invalid or not public' };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async collectSecurityMetrics(): Promise<{
|
||||
|
||||
@@ -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,8 +69,11 @@ export class TargetProfileHandler {
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
||||
createdBy: userId,
|
||||
});
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
return { success: true, id };
|
||||
},
|
||||
),
|
||||
@@ -109,6 +95,7 @@ export class TargetProfileHandler {
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
||||
});
|
||||
// Re-apply routes and refresh VPN client security to update access
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
/**
|
||||
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
|
||||
* Handler for OpsServer user accounts. Registers on adminRouter,
|
||||
* so admin middleware enforces auth + role check before the handler runs.
|
||||
* User data is owned by AdminHandler; this handler just exposes a safe
|
||||
* projection of it via TypedRequest.
|
||||
@@ -16,15 +17,57 @@ export class UsersHandler {
|
||||
private registerHandlers(): void {
|
||||
const router = this.opsServerRef.adminRouter;
|
||||
|
||||
// List users (admin-only, read-only)
|
||||
// List users (admin-only)
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||
'listUsers',
|
||||
async (_dataArg) => {
|
||||
const users = this.opsServerRef.adminHandler.listUsers();
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'users:read',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
const users = await this.opsServerRef.adminHandler.listUsers();
|
||||
return { users };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateUser>(
|
||||
'createUser',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'users:manage',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
return this.opsServerRef.adminHandler.createUser({
|
||||
email: dataArg.email,
|
||||
name: dataArg.name,
|
||||
role: dataArg.role,
|
||||
password: dataArg.password,
|
||||
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteUser>(
|
||||
'deleteUser',
|
||||
async (dataArg) => {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'users:manage',
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
return this.opsServerRef.adminHandler.deleteUser({
|
||||
id: dataArg.id,
|
||||
requestingUserId: auth.userId,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
type TAuthContext = {
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
token?: interfaces.data.IStoredApiToken;
|
||||
};
|
||||
|
||||
export class WorkHosterHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<TAuthContext> {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, request, {
|
||||
scope: requiredScope,
|
||||
requireAdminIdentity: requiredScope?.endsWith(':write'),
|
||||
});
|
||||
return { userId: auth.userId, isAdmin: auth.isAdmin, token: auth.token };
|
||||
}
|
||||
|
||||
private async requireAdmin(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
scope: interfaces.data.TApiTokenScope = 'gateway-clients:write',
|
||||
): Promise<string> {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, request, {
|
||||
scope,
|
||||
requireAdminIdentity: true,
|
||||
requireAdminToken: true,
|
||||
});
|
||||
return auth.userId;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
|
||||
'getGatewayCapabilities',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'gateway-clients:read');
|
||||
return { capabilities: this.getGatewayCapabilities() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientContext>(
|
||||
'getGatewayClientContext',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
|
||||
return {
|
||||
context: this.getGatewayClientContext(auth),
|
||||
capabilities: this.getGatewayCapabilities(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListGatewayClients>(
|
||||
'listGatewayClients',
|
||||
async (dataArg) => {
|
||||
await this.requireAdmin(dataArg, 'gateway-clients:read');
|
||||
return { gatewayClients: await this.listManagedGatewayClients() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClient>(
|
||||
'createGatewayClient',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAdmin(dataArg);
|
||||
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
||||
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
|
||||
try {
|
||||
const gatewayClient = await manager.createClient({
|
||||
id: dataArg.id,
|
||||
type: dataArg.type,
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
hostnamePatterns: dataArg.hostnamePatterns,
|
||||
allowedRouteTargets: dataArg.allowedRouteTargets,
|
||||
capabilities: dataArg.capabilities,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, gatewayClient };
|
||||
} catch (error) {
|
||||
return { success: false, message: (error as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateGatewayClient>(
|
||||
'updateGatewayClient',
|
||||
async (dataArg) => {
|
||||
await this.requireAdmin(dataArg);
|
||||
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
||||
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
|
||||
const gatewayClient = await manager.updateClient(dataArg.id, {
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
hostnamePatterns: dataArg.hostnamePatterns,
|
||||
allowedRouteTargets: dataArg.allowedRouteTargets,
|
||||
capabilities: dataArg.capabilities,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
return gatewayClient
|
||||
? { success: true, gatewayClient }
|
||||
: { success: false, message: 'Gateway client not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteGatewayClient>(
|
||||
'deleteGatewayClient',
|
||||
async (dataArg) => {
|
||||
await this.requireAdmin(dataArg);
|
||||
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
||||
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
|
||||
const success = await manager.deleteClient(dataArg.id);
|
||||
return { success, message: success ? undefined : 'Gateway client not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClientToken>(
|
||||
'createGatewayClientToken',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAdmin(dataArg, 'tokens:manage');
|
||||
const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId);
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!gatewayClient || !gatewayClient.enabled) {
|
||||
return { success: false, message: 'Gateway client not found or disabled' };
|
||||
}
|
||||
if (!tokenManager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
}
|
||||
const result = await tokenManager.createToken(
|
||||
dataArg.name?.trim() || `${gatewayClient.name} Token`,
|
||||
['gateway-clients:read', 'gateway-clients:write'],
|
||||
dataArg.expiresInDays ?? null,
|
||||
userId,
|
||||
{
|
||||
role: 'gatewayClient',
|
||||
scopes: ['gateway-clients:read', 'gateway-clients:write'],
|
||||
gatewayClient: { type: gatewayClient.type, id: gatewayClient.id },
|
||||
hostnamePatterns: gatewayClient.hostnamePatterns,
|
||||
allowedRouteTargets: gatewayClient.allowedRouteTargets,
|
||||
capabilities: gatewayClient.capabilities,
|
||||
},
|
||||
);
|
||||
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDomains>(
|
||||
'getGatewayClientDomains',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
|
||||
this.assertCapability(auth, 'readDomains');
|
||||
return { domains: await this.listGatewayClientDomains(auth, dataArg.gatewayClientId) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDnsRecords>(
|
||||
'getGatewayClientDnsRecords',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
|
||||
this.assertCapability(auth, 'readDnsRecords');
|
||||
return { records: await this.listGatewayClientDnsRecords(auth, dataArg.gatewayClientId) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkHosterDomains>(
|
||||
'getWorkHosterDomains',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg, 'workhosters:read');
|
||||
this.assertCapability(auth, 'readDomains');
|
||||
return { domains: await this.listGatewayClientDomains(auth) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncGatewayClientRoute>(
|
||||
'syncGatewayClientRoute',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg, 'gateway-clients:write');
|
||||
this.assertCapability(auth, 'syncRoutes');
|
||||
return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppRoute>(
|
||||
'syncWorkAppRoute',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg, 'workhosters:write');
|
||||
this.assertCapability(auth, 'syncRoutes');
|
||||
const ownership: interfaces.data.IGatewayClientOwnership = {
|
||||
gatewayClientType: dataArg.ownership.workHosterType,
|
||||
gatewayClientId: dataArg.ownership.workHosterId,
|
||||
appId: dataArg.ownership.workAppId,
|
||||
hostname: dataArg.ownership.hostname,
|
||||
};
|
||||
return await this.syncGatewayClientRoute(auth, ownership, dataArg.route, dataArg.enabled, dataArg.delete);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkAppMailIdentities>(
|
||||
'getWorkAppMailIdentities',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'workhosters:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||
if (!manager) return { identities: [] };
|
||||
return { identities: await manager.listMailIdentities(dataArg.ownership) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppMailIdentity>(
|
||||
'syncWorkAppMailIdentity',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg, 'workhosters:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'WorkApp mail manager not initialized' };
|
||||
}
|
||||
try {
|
||||
return await manager.syncMailIdentity(dataArg, auth.userId);
|
||||
} catch (error) {
|
||||
return { success: false, message: (error as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
return {
|
||||
routes: {
|
||||
read: Boolean(dcRouter.routeConfigManager),
|
||||
write: Boolean(dcRouter.routeConfigManager),
|
||||
idempotentSync: Boolean(dcRouter.routeConfigManager),
|
||||
},
|
||||
domains: {
|
||||
read: Boolean(dcRouter.dnsManager),
|
||||
write: Boolean(dcRouter.dnsManager),
|
||||
},
|
||||
certificates: {
|
||||
read: Boolean(dcRouter.smartProxy),
|
||||
export: Boolean(dcRouter.smartProxy),
|
||||
forceRenew: Boolean(dcRouter.smartProxy),
|
||||
},
|
||||
email: {
|
||||
domains: Boolean(dcRouter.emailDomainManager),
|
||||
inbound: Boolean(dcRouter.emailServer),
|
||||
outbound: Boolean(dcRouter.emailServer),
|
||||
},
|
||||
remoteIngress: {
|
||||
enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled),
|
||||
},
|
||||
dns: {
|
||||
authoritative: Boolean(dcRouter.options.dnsScopes?.length),
|
||||
providerManaged: Boolean(dcRouter.dnsManager),
|
||||
},
|
||||
http3: {
|
||||
enabled: dcRouter.options.http3?.enabled !== false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getGatewayClientContext(auth: TAuthContext): interfaces.data.IGatewayClientContext {
|
||||
const policy = auth.token?.policy;
|
||||
const role = auth.isAdmin ? 'admin' : policy?.role || 'operator';
|
||||
return {
|
||||
role,
|
||||
scopes: auth.token?.scopes || ['*'],
|
||||
gatewayClient: policy?.gatewayClient,
|
||||
hostnamePatterns: policy?.hostnamePatterns || [],
|
||||
allowedRouteTargets: policy?.allowedRouteTargets || [],
|
||||
capabilities: policy?.capabilities || {},
|
||||
};
|
||||
}
|
||||
|
||||
private async listManagedGatewayClients(): Promise<interfaces.data.IGatewayClient[]> {
|
||||
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
||||
if (!manager) return [];
|
||||
const clients = await manager.listClients();
|
||||
const tokens = this.opsServerRef.dcRouterRef.apiTokenManager?.listTokens() || [];
|
||||
return clients.map((client) => ({
|
||||
...client,
|
||||
tokenCount: tokens.filter((token) => token.policy?.gatewayClient?.id === client.id).length,
|
||||
}));
|
||||
}
|
||||
|
||||
private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string {
|
||||
return [
|
||||
ownership.workHosterType,
|
||||
ownership.workHosterId,
|
||||
ownership.workAppId,
|
||||
ownership.hostname,
|
||||
].map((part) => part.trim()).join(':');
|
||||
}
|
||||
|
||||
private assertCapability(
|
||||
auth: TAuthContext,
|
||||
capability: keyof NonNullable<interfaces.data.IApiTokenPolicy['capabilities']>,
|
||||
): void {
|
||||
if (auth.isAdmin) return;
|
||||
const policy = auth.token?.policy;
|
||||
if (!policy || policy.role !== 'gatewayClient') return;
|
||||
if (policy.capabilities?.[capability] === true) return;
|
||||
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
|
||||
}
|
||||
|
||||
private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
|
||||
const policyClient = auth.token?.policy?.gatewayClient;
|
||||
if (!policyClient) return requestedId;
|
||||
if (requestedId && requestedId !== policyClient.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot access another gateway client');
|
||||
}
|
||||
return policyClient.id;
|
||||
}
|
||||
|
||||
private resolveGatewayClientOwnership(
|
||||
auth: TAuthContext,
|
||||
ownership: interfaces.data.IGatewayClientOwnership,
|
||||
): Required<interfaces.data.IGatewayClientOwnership> {
|
||||
const policy = auth.token?.policy;
|
||||
if (policy?.role === 'gatewayClient') {
|
||||
if (!policy.gatewayClient) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
|
||||
}
|
||||
if (ownership.gatewayClientType && ownership.gatewayClientType !== policy.gatewayClient.type) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
|
||||
}
|
||||
if (ownership.gatewayClientId && ownership.gatewayClientId !== policy.gatewayClient.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
|
||||
}
|
||||
return {
|
||||
gatewayClientType: policy.gatewayClient.type,
|
||||
gatewayClientId: policy.gatewayClient.id,
|
||||
appId: ownership.appId,
|
||||
hostname: ownership.hostname,
|
||||
};
|
||||
}
|
||||
|
||||
if (!ownership.gatewayClientType || !ownership.gatewayClientId) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing type or id');
|
||||
}
|
||||
return ownership as Required<interfaces.data.IGatewayClientOwnership>;
|
||||
}
|
||||
|
||||
private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
|
||||
const policy = auth.token?.policy;
|
||||
if (!policy || policy.role !== 'gatewayClient') return;
|
||||
if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
|
||||
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
|
||||
}
|
||||
}
|
||||
|
||||
private assertRouteTargetsAllowed(auth: TAuthContext, route?: interfaces.data.IDcRouterRouteConfig): void {
|
||||
const policy = auth.token?.policy;
|
||||
if (!policy || policy.role !== 'gatewayClient' || !route) return;
|
||||
const allowedTargets = policy.allowedRouteTargets || [];
|
||||
if (allowedTargets.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
|
||||
}
|
||||
const targets = ((route.action as any)?.targets || []) as Array<{ host?: string; port?: number }>;
|
||||
for (const target of targets) {
|
||||
const host = String(target.host || '').trim().toLowerCase();
|
||||
const port = Number(target.port);
|
||||
const allowed = allowedTargets.some((allowedTarget) => {
|
||||
return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
|
||||
});
|
||||
if (!allowed) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
|
||||
const normalizedHostname = hostname.trim().toLowerCase();
|
||||
if (!normalizedHostname) return false;
|
||||
for (const pattern of patterns) {
|
||||
const normalizedPattern = pattern.trim().toLowerCase();
|
||||
if (!normalizedPattern) continue;
|
||||
if (normalizedPattern === normalizedHostname) return true;
|
||||
if (normalizedPattern.startsWith('*.')) {
|
||||
const suffix = normalizedPattern.slice(2);
|
||||
if (!normalizedHostname.endsWith(`.${suffix}`)) continue;
|
||||
const prefix = normalizedHostname.slice(0, -(suffix.length + 1));
|
||||
if (prefix && !prefix.includes('.')) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] {
|
||||
const domains = (route.match as any)?.domains;
|
||||
if (Array.isArray(domains)) {
|
||||
return domains.map((domain) => String(domain).trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
if (typeof domains === 'string') {
|
||||
return domains.split(',').map((domain) => domain.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private getOwnedRoutes(gatewayClientId?: string): interfaces.data.IMergedRoute[] {
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) return [];
|
||||
return manager.getMergedRoutes().routes.filter((route) => {
|
||||
const metadata = route.metadata;
|
||||
if (!metadata) return false;
|
||||
const ownerType = metadata.ownerType;
|
||||
const isGatewayOwned = ownerType === 'gatewayClient' || ownerType === 'workhoster';
|
||||
if (!isGatewayOwned) return false;
|
||||
const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId;
|
||||
return gatewayClientId ? routeGatewayClientId === gatewayClientId : true;
|
||||
});
|
||||
}
|
||||
|
||||
private async listGatewayClientDomains(
|
||||
auth: TAuthContext,
|
||||
requestedGatewayClientId?: string,
|
||||
): Promise<interfaces.data.IGatewayClientDomain[]> {
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return [];
|
||||
const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
|
||||
const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
|
||||
const routeHostnames = ownedRoutes.flatMap((route) => this.getRouteHostnames(route.route));
|
||||
const docs = await dnsManager.listDomains();
|
||||
|
||||
return docs
|
||||
.filter((domainDoc) => {
|
||||
if (!auth.token?.policy || auth.token.policy.role !== 'gatewayClient') return true;
|
||||
return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name));
|
||||
})
|
||||
.map((domainDoc) => {
|
||||
const domain = dnsManager.toPublicDomain(domainDoc);
|
||||
const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId);
|
||||
const serviceCount = routeHostnames.filter((hostname) => this.isHostnameInDomain(hostname, domain.name)).length;
|
||||
return {
|
||||
...domain,
|
||||
serviceCount,
|
||||
managePath: `/domains/${domain.id}`,
|
||||
capabilities: {
|
||||
canCreateSubdomains: canManageDnsRecords,
|
||||
canManageDnsRecords,
|
||||
canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy),
|
||||
canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager),
|
||||
},
|
||||
} satisfies interfaces.data.IGatewayClientDomain;
|
||||
});
|
||||
}
|
||||
|
||||
private async listGatewayClientDnsRecords(
|
||||
auth: TAuthContext,
|
||||
requestedGatewayClientId?: string,
|
||||
): Promise<interfaces.data.IGatewayClientDnsRecord[]> {
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return [];
|
||||
const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
|
||||
const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
|
||||
const domains = await dnsManager.listDomains();
|
||||
const records: interfaces.data.IGatewayClientDnsRecord[] = [];
|
||||
|
||||
for (const route of ownedRoutes) {
|
||||
const metadata = route.metadata;
|
||||
if (!metadata) continue;
|
||||
const gatewayClientType = metadata.gatewayClientType || metadata.workHosterType || 'custom';
|
||||
const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId || '';
|
||||
const appId = metadata.gatewayClientAppId || metadata.workAppId || '';
|
||||
|
||||
for (const hostname of this.getRouteHostnames(route.route)) {
|
||||
if (auth.token?.policy?.role === 'gatewayClient' && !this.matchesHostnamePatterns(hostname, auth.token.policy.hostnamePatterns || [])) {
|
||||
continue;
|
||||
}
|
||||
const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name));
|
||||
const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : [];
|
||||
const matchingRecords = domainRecords.filter((record) => record.name === hostname);
|
||||
if (matchingRecords.length === 0) {
|
||||
records.push({
|
||||
id: `missing:${hostname}`,
|
||||
domainId: domainDoc?.id || '',
|
||||
domainName: domainDoc?.name,
|
||||
name: hostname,
|
||||
type: 'MISSING',
|
||||
value: '',
|
||||
ttl: 0,
|
||||
source: 'local',
|
||||
status: 'missing',
|
||||
gatewayClientType,
|
||||
gatewayClientId: routeGatewayClientId,
|
||||
appId,
|
||||
hostname,
|
||||
routeId: route.id,
|
||||
managePath: domainDoc ? `/domains/${domainDoc.id}/dns` : '/domains',
|
||||
createdAt: route.createdAt || 0,
|
||||
updatedAt: route.updatedAt || 0,
|
||||
createdBy: '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const recordDoc of matchingRecords) {
|
||||
const record = dnsManager.toPublicRecord(recordDoc);
|
||||
records.push({
|
||||
...record,
|
||||
domainName: domainDoc?.name,
|
||||
status: 'active',
|
||||
gatewayClientType,
|
||||
gatewayClientId: routeGatewayClientId,
|
||||
appId,
|
||||
hostname,
|
||||
routeId: route.id,
|
||||
managePath: `/dns-records/${record.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private isHostnameInDomain(hostname: string, domainName: string): boolean {
|
||||
const normalizedHostname = hostname.trim().toLowerCase();
|
||||
const normalizedDomainName = domainName.trim().toLowerCase();
|
||||
return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`);
|
||||
}
|
||||
|
||||
private async syncGatewayClientRoute(
|
||||
auth: TAuthContext,
|
||||
ownership: interfaces.data.IGatewayClientOwnership,
|
||||
route?: interfaces.data.IDcRouterRouteConfig,
|
||||
enabled?: boolean,
|
||||
deleteRoute?: boolean,
|
||||
): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
|
||||
const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
|
||||
this.assertGatewayClientOwnership(auth, resolvedOwnership);
|
||||
this.assertRouteTargetsAllowed(auth, route);
|
||||
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
|
||||
const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
|
||||
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
|
||||
|
||||
if (deleteRoute) {
|
||||
if (!existingRoute) {
|
||||
return { success: true, action: 'unchanged' };
|
||||
}
|
||||
const result = await manager.deleteRoute(existingRoute.id);
|
||||
return result.success
|
||||
? { success: true, action: 'deleted', routeId: existingRoute.id }
|
||||
: { success: false, message: result.message };
|
||||
}
|
||||
|
||||
if (!route) {
|
||||
return { success: false, message: 'route is required unless delete=true' };
|
||||
}
|
||||
|
||||
const metadata: interfaces.data.IRouteMetadata = {
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: resolvedOwnership.gatewayClientType,
|
||||
gatewayClientId: resolvedOwnership.gatewayClientId,
|
||||
gatewayClientAppId: resolvedOwnership.appId,
|
||||
workHosterType: resolvedOwnership.gatewayClientType,
|
||||
workHosterId: resolvedOwnership.gatewayClientId,
|
||||
workAppId: resolvedOwnership.appId,
|
||||
externalKey,
|
||||
};
|
||||
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
|
||||
|
||||
if (existingRoute) {
|
||||
const result = await manager.updateRoute(existingRoute.id, {
|
||||
route: normalizedRoute,
|
||||
enabled: enabled ?? true,
|
||||
metadata,
|
||||
});
|
||||
return result.success
|
||||
? { success: true, action: 'updated', routeId: existingRoute.id }
|
||||
: { success: false, message: result.message };
|
||||
}
|
||||
|
||||
const routeId = await manager.createRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
|
||||
return { success: true, action: 'created', routeId };
|
||||
}
|
||||
|
||||
private buildGatewayClientExternalKey(ownership: Required<interfaces.data.IGatewayClientOwnership>): string {
|
||||
return [
|
||||
ownership.gatewayClientType,
|
||||
ownership.gatewayClientId,
|
||||
ownership.appId,
|
||||
ownership.hostname,
|
||||
].map((part) => part.trim()).join(':');
|
||||
}
|
||||
|
||||
private normalizeWorkAppRoute(
|
||||
route: interfaces.data.IDcRouterRouteConfig,
|
||||
ownership: interfaces.data.IWorkAppRouteOwnership,
|
||||
externalKey: string,
|
||||
): interfaces.data.IDcRouterRouteConfig {
|
||||
const normalizedRoute = { ...route };
|
||||
if (!normalizedRoute.name) {
|
||||
normalizedRoute.name = `workapp-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
|
||||
}
|
||||
return normalizedRoute;
|
||||
}
|
||||
|
||||
private normalizeGatewayClientRoute(
|
||||
route: interfaces.data.IDcRouterRouteConfig,
|
||||
ownership: Required<interfaces.data.IGatewayClientOwnership>,
|
||||
externalKey: string,
|
||||
): interfaces.data.IDcRouterRouteConfig {
|
||||
const normalizedRoute = { ...route };
|
||||
if (!normalizedRoute.name) {
|
||||
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
|
||||
}
|
||||
return normalizedRoute;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -41,6 +41,13 @@ export {
|
||||
typedsocket,
|
||||
}
|
||||
|
||||
// @idp.global scope
|
||||
import * as idpSdkServer from '@idp.global/sdk/server';
|
||||
|
||||
export {
|
||||
idpSdkServer,
|
||||
}
|
||||
|
||||
// @push.rocks scope
|
||||
import * as projectinfo from '@push.rocks/projectinfo';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ await router.start();
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import * as plugins from '../plugins.js';
|
||||
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { RemoteIngressEdgeDoc } from '../db/index.js';
|
||||
|
||||
interface IRemoteIngressFirewallConfig {
|
||||
blockedIps?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
||||
*/
|
||||
@@ -31,6 +35,7 @@ function extractPorts(portRange: number | Array<number | { from: number; to: num
|
||||
export class RemoteIngressManager {
|
||||
private edges: Map<string, IRemoteIngress> = new Map();
|
||||
private routes: IDcRouterRouteConfig[] = [];
|
||||
private firewallConfig?: IRemoteIngressFirewallConfig;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
@@ -69,6 +74,13 @@ export class RemoteIngressManager {
|
||||
this.routes = routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the full desired firewall snapshot pushed to all edges.
|
||||
*/
|
||||
public setFirewallConfig(firewallConfig?: IRemoteIngressFirewallConfig): void {
|
||||
this.firewallConfig = firewallConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
|
||||
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
|
||||
@@ -305,8 +317,8 @@ export class RemoteIngressManager {
|
||||
* Get the list of allowed edges (enabled only) for the Rust hub.
|
||||
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
|
||||
*/
|
||||
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> {
|
||||
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = [];
|
||||
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> {
|
||||
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = [];
|
||||
for (const edge of this.edges.values()) {
|
||||
if (edge.enabled) {
|
||||
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
|
||||
@@ -315,6 +327,7 @@ export class RemoteIngressManager {
|
||||
secret: edge.secret,
|
||||
listenPorts: this.getEffectiveListenPorts(edge),
|
||||
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
|
||||
...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../db/index.js';
|
||||
import type {
|
||||
IIpIntelligenceRecord,
|
||||
ISecurityBlockRule,
|
||||
ISecurityCompiledPolicy,
|
||||
ISecurityPolicyAuditEvent,
|
||||
TSecurityBlockRuleMatchMode,
|
||||
TSecurityBlockRuleType,
|
||||
} from '../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
export interface ISecurityPolicyManagerOptions {
|
||||
intelligenceRefreshMs?: number;
|
||||
onPolicyChanged?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressFirewallSnapshot {
|
||||
blockedIps: string[];
|
||||
}
|
||||
|
||||
const OBSERVED_IP_QUEUE_LIMIT = 512;
|
||||
const OBSERVED_IP_BATCH_LIMIT = 20;
|
||||
const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
|
||||
const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
|
||||
|
||||
export class SecurityPolicyManager {
|
||||
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
|
||||
cacheTtl: 24 * 60 * 60 * 1000,
|
||||
ipIntelligenceTimeout: 5_000,
|
||||
});
|
||||
private readonly intelligenceRefreshMs: number;
|
||||
private readonly inFlightObservations = new Map<string, Promise<void>>();
|
||||
private readonly queuedObservations = new Set<string>();
|
||||
private readonly observationQueue: string[] = [];
|
||||
private readonly lastQueuedAt = new Map<string, number>();
|
||||
private activeQueuedObservations = 0;
|
||||
private queueDrainScheduled = false;
|
||||
private isStopping = false;
|
||||
private readonly onPolicyChanged?: () => void | Promise<void>;
|
||||
|
||||
constructor(options: ISecurityPolicyManagerOptions = {}) {
|
||||
this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000;
|
||||
this.onPolicyChanged = options.onPolicyChanged;
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', 'SecurityPolicyManager started');
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.isStopping = true;
|
||||
this.observationQueue.length = 0;
|
||||
this.queuedObservations.clear();
|
||||
await this.smartNetwork.stop();
|
||||
}
|
||||
|
||||
public async observeIps(ips: string[]): Promise<void> {
|
||||
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
||||
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
|
||||
}
|
||||
|
||||
public queueObservedIps(ips: string[]): void {
|
||||
if (this.isStopping) return;
|
||||
|
||||
const now = Date.now();
|
||||
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
||||
|
||||
for (const ip of uniqueIps.slice(0, OBSERVED_IP_BATCH_LIMIT)) {
|
||||
if (!this.isPublicIp(ip)) continue;
|
||||
if (this.inFlightObservations.has(ip) || this.queuedObservations.has(ip)) continue;
|
||||
|
||||
const lastQueuedAt = this.lastQueuedAt.get(ip);
|
||||
if (lastQueuedAt && now - lastQueuedAt < OBSERVED_IP_REQUEUE_THROTTLE_MS) continue;
|
||||
|
||||
if (this.observationQueue.length >= OBSERVED_IP_QUEUE_LIMIT) {
|
||||
const droppedIp = this.observationQueue.shift();
|
||||
if (droppedIp) this.queuedObservations.delete(droppedIp);
|
||||
}
|
||||
|
||||
this.observationQueue.push(ip);
|
||||
this.queuedObservations.add(ip);
|
||||
this.lastQueuedAt.set(ip, now);
|
||||
}
|
||||
|
||||
this.pruneQueuedIpMemory(now);
|
||||
this.scheduleQueueDrain();
|
||||
}
|
||||
|
||||
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
|
||||
const ip = this.normalizeIp(ipAddress);
|
||||
if (!ip || !this.isPublicIp(ip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingObservation = this.inFlightObservations.get(ip);
|
||||
if (existingObservation) {
|
||||
await existingObservation;
|
||||
if (!options.force) return;
|
||||
}
|
||||
|
||||
const observationPromise = this.performObserveIp(ip, options).finally(() => {
|
||||
if (this.inFlightObservations.get(ip) === observationPromise) {
|
||||
this.inFlightObservations.delete(ip);
|
||||
}
|
||||
});
|
||||
this.inFlightObservations.set(ip, observationPromise);
|
||||
await observationPromise;
|
||||
}
|
||||
|
||||
private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
let doc = await IpIntelligenceDoc.findByIp(ip);
|
||||
if (doc && !options.force && now - doc.updatedAt < this.intelligenceRefreshMs) {
|
||||
if (now - doc.lastSeenAt > 60_000) {
|
||||
doc.lastSeenAt = now;
|
||||
doc.seenCount = (doc.seenCount || 0) + 1;
|
||||
await doc.save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const intelligence = await this.smartNetwork.getIpIntelligence(ip);
|
||||
if (!doc) {
|
||||
doc = new IpIntelligenceDoc();
|
||||
doc.ipAddress = ip;
|
||||
doc.firstSeenAt = now;
|
||||
}
|
||||
Object.assign(doc, intelligence);
|
||||
doc.lastSeenAt = now;
|
||||
doc.updatedAt = now;
|
||||
doc.seenCount = (doc.seenCount || 0) + 1;
|
||||
await doc.save();
|
||||
|
||||
if (await this.matchesAnyReactiveRule(doc)) {
|
||||
await this.notifyPolicyChanged();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async listBlockRules(): Promise<ISecurityBlockRule[]> {
|
||||
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
|
||||
}
|
||||
|
||||
public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
|
||||
const limit = Number.isInteger(options.limit) && options.limit! > 0
|
||||
? Math.min(options.limit!, 500)
|
||||
: undefined;
|
||||
|
||||
let docs: IpIntelligenceDoc[];
|
||||
if (options.ipAddresses?.length) {
|
||||
const ips = [...new Set(options.ipAddresses.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
||||
const results = await Promise.all(ips.map((ip) => IpIntelligenceDoc.findByIp(ip)));
|
||||
docs = results.filter(Boolean) as IpIntelligenceDoc[];
|
||||
} else {
|
||||
docs = await IpIntelligenceDoc.findAll();
|
||||
}
|
||||
|
||||
const sortedDocs = docs.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
|
||||
return (limit ? sortedDocs.slice(0, limit) : sortedDocs).map((doc) => this.intelligenceFromDoc(doc));
|
||||
}
|
||||
|
||||
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
|
||||
const ip = this.normalizeIp(ipAddress);
|
||||
if (!ip || !this.isPublicIp(ip)) {
|
||||
return null;
|
||||
}
|
||||
await this.observeIp(ip, { force: true });
|
||||
const doc = await IpIntelligenceDoc.findByIp(ip);
|
||||
return doc ? this.intelligenceFromDoc(doc) : null;
|
||||
}
|
||||
|
||||
private scheduleQueueDrain(): void {
|
||||
if (this.queueDrainScheduled || this.isStopping) return;
|
||||
this.queueDrainScheduled = true;
|
||||
setTimeout(() => {
|
||||
this.queueDrainScheduled = false;
|
||||
this.drainObservationQueue();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private drainObservationQueue(): void {
|
||||
if (this.isStopping) return;
|
||||
|
||||
while (
|
||||
this.activeQueuedObservations < OBSERVED_IP_QUEUE_CONCURRENCY &&
|
||||
this.observationQueue.length > 0
|
||||
) {
|
||||
const ip = this.observationQueue.shift()!;
|
||||
this.queuedObservations.delete(ip);
|
||||
this.activeQueuedObservations++;
|
||||
void this.observeIp(ip)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
this.activeQueuedObservations--;
|
||||
if (this.observationQueue.length > 0) {
|
||||
this.scheduleQueueDrain();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private pruneQueuedIpMemory(now: number): void {
|
||||
if (this.lastQueuedAt.size <= OBSERVED_IP_QUEUE_LIMIT * 2) return;
|
||||
for (const [ip, lastQueuedAt] of this.lastQueuedAt) {
|
||||
if (now - lastQueuedAt > OBSERVED_IP_REQUEUE_THROTTLE_MS * 2) {
|
||||
this.lastQueuedAt.delete(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
|
||||
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
|
||||
id: doc.id,
|
||||
action: doc.action,
|
||||
actor: doc.actor,
|
||||
details: doc.details,
|
||||
createdAt: doc.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
private intelligenceFromDoc(doc: IpIntelligenceDoc): IIpIntelligenceRecord {
|
||||
return {
|
||||
ipAddress: doc.ipAddress,
|
||||
asn: doc.asn,
|
||||
asnOrg: doc.asnOrg,
|
||||
registrantOrg: doc.registrantOrg,
|
||||
registrantCountry: doc.registrantCountry,
|
||||
networkRange: doc.networkRange,
|
||||
networkCidrs: doc.networkCidrs,
|
||||
abuseContact: doc.abuseContact,
|
||||
country: doc.country,
|
||||
countryCode: doc.countryCode,
|
||||
city: doc.city,
|
||||
latitude: doc.latitude,
|
||||
longitude: doc.longitude,
|
||||
accuracyRadius: doc.accuracyRadius,
|
||||
timezone: doc.timezone,
|
||||
firstSeenAt: doc.firstSeenAt,
|
||||
lastSeenAt: doc.lastSeenAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
seenCount: doc.seenCount,
|
||||
};
|
||||
}
|
||||
|
||||
public async createBlockRule(input: {
|
||||
type: TSecurityBlockRuleType;
|
||||
value: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
reason?: string;
|
||||
enabled?: boolean;
|
||||
}, actor = 'system'): Promise<ISecurityBlockRule> {
|
||||
const now = Date.now();
|
||||
const doc = new SecurityBlockRuleDoc();
|
||||
doc.id = plugins.uuid.v4();
|
||||
doc.type = input.type;
|
||||
doc.value = input.value.trim();
|
||||
doc.matchMode = input.matchMode;
|
||||
doc.reason = input.reason;
|
||||
doc.enabled = input.enabled ?? true;
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
doc.createdBy = actor;
|
||||
await doc.save();
|
||||
await this.writeAudit('createBlockRule', actor, { rule: this.ruleFromDoc(doc) });
|
||||
await this.notifyPolicyChanged();
|
||||
return this.ruleFromDoc(doc);
|
||||
}
|
||||
|
||||
public async updateBlockRule(id: string, patch: Partial<Pick<ISecurityBlockRule, 'value' | 'matchMode' | 'reason' | 'enabled'>>, actor = 'system'): Promise<ISecurityBlockRule | null> {
|
||||
const doc = await SecurityBlockRuleDoc.findById(id);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
if (patch.value !== undefined) doc.value = patch.value.trim();
|
||||
if (patch.matchMode !== undefined) doc.matchMode = patch.matchMode;
|
||||
if (patch.reason !== undefined) doc.reason = patch.reason;
|
||||
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
await this.writeAudit('updateBlockRule', actor, { id, patch });
|
||||
await this.notifyPolicyChanged();
|
||||
return this.ruleFromDoc(doc);
|
||||
}
|
||||
|
||||
public async deleteBlockRule(id: string, actor = 'system'): Promise<boolean> {
|
||||
const doc = await SecurityBlockRuleDoc.findById(id);
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
await doc.delete();
|
||||
await this.writeAudit('deleteBlockRule', actor, { id });
|
||||
await this.notifyPolicyChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async compilePolicy(): Promise<ISecurityCompiledPolicy> {
|
||||
const rules = await SecurityBlockRuleDoc.findEnabled();
|
||||
const intelligenceDocs = await IpIntelligenceDoc.findAll();
|
||||
const blockedIps = new Set<string>();
|
||||
const blockedCidrs = new Set<string>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const normalizedValue = rule.value.trim();
|
||||
if (!normalizedValue) continue;
|
||||
|
||||
if (rule.type === 'ip') {
|
||||
const ip = this.normalizeIp(normalizedValue);
|
||||
if (ip && plugins.net.isIP(ip)) blockedIps.add(ip);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.type === 'cidr') {
|
||||
for (const cidr of this.normalizeNetworkEntries(normalizedValue)) {
|
||||
blockedCidrs.add(cidr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const doc of intelligenceDocs) {
|
||||
if (!this.ruleMatchesIntelligence(rule, doc)) continue;
|
||||
const networkEntries = this.normalizeNetworkEntryList([
|
||||
...(doc.networkCidrs || []),
|
||||
doc.networkRange,
|
||||
]);
|
||||
if (networkEntries.length > 0) {
|
||||
for (const cidr of networkEntries) {
|
||||
blockedCidrs.add(cidr);
|
||||
}
|
||||
} else if (this.normalizeIp(doc.ipAddress)) {
|
||||
blockedIps.add(this.normalizeIp(doc.ipAddress)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blockedIps: [...blockedIps].sort(),
|
||||
blockedCidrs: [...blockedCidrs].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
public async compileSmartProxyPolicy(): Promise<ISecurityCompiledPolicy> {
|
||||
return await this.compilePolicy();
|
||||
}
|
||||
|
||||
public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot> {
|
||||
const policy = await this.compilePolicy();
|
||||
const blockedIps = [
|
||||
...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4),
|
||||
...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4),
|
||||
];
|
||||
return { blockedIps };
|
||||
}
|
||||
|
||||
private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise<boolean> {
|
||||
const rules = await SecurityBlockRuleDoc.findEnabled();
|
||||
return rules.some((rule) => rule.type === 'asn' || rule.type === 'organization'
|
||||
? this.ruleMatchesIntelligence(rule, doc)
|
||||
: false);
|
||||
}
|
||||
|
||||
private ruleMatchesIntelligence(rule: SecurityBlockRuleDoc, doc: IpIntelligenceDoc): boolean {
|
||||
const value = rule.value.trim().toLowerCase();
|
||||
if (!value) return false;
|
||||
|
||||
if (rule.type === 'asn') {
|
||||
return String(doc.asn ?? '') === value.replace(/^as/i, '');
|
||||
}
|
||||
|
||||
if (rule.type === 'organization') {
|
||||
const candidates = [doc.asnOrg, doc.registrantOrg]
|
||||
.filter(Boolean)
|
||||
.map((candidate) => candidate!.toLowerCase());
|
||||
if (rule.matchMode === 'exact') {
|
||||
return candidates.some((candidate) => candidate === value);
|
||||
}
|
||||
return candidates.some((candidate) => candidate.includes(value));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private normalizeIp(ipAddress: string): string | undefined {
|
||||
const ip = ipAddress.trim();
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
return ip.slice('::ffff:'.length);
|
||||
}
|
||||
return plugins.net.isIP(ip) ? ip : undefined;
|
||||
}
|
||||
|
||||
private normalizeCidr(value: string): string | undefined {
|
||||
const [rawIp, rawPrefix] = value.trim().split('/');
|
||||
if (!rawIp || !rawPrefix) return undefined;
|
||||
const ip = this.normalizeIp(rawIp);
|
||||
if (!ip) return undefined;
|
||||
const prefix = Number(rawPrefix);
|
||||
const maxPrefix = plugins.net.isIP(ip) === 4 ? 32 : 128;
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) return undefined;
|
||||
return `${ip}/${prefix}`;
|
||||
}
|
||||
|
||||
private normalizeNetworkEntries(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const cidr = this.normalizeCidr(trimmed);
|
||||
if (cidr) return [cidr];
|
||||
|
||||
const rangeParts = trimmed.split(/\s+-\s+/);
|
||||
if (rangeParts.length === 2) {
|
||||
return this.ipv4RangeToCidrs(rangeParts[0], rangeParts[1]);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeNetworkEntryList(values: Array<string | null | undefined>): string[] {
|
||||
const cidrs = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
for (const entry of value.split(',').map((part) => part.trim()).filter(Boolean)) {
|
||||
for (const cidr of this.normalizeNetworkEntries(entry)) {
|
||||
cidrs.add(cidr);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...cidrs];
|
||||
}
|
||||
|
||||
private ipv4RangeToCidrs(startIp: string, endIp: string): string[] {
|
||||
const start = this.ipv4ToBigInt(startIp);
|
||||
const end = this.ipv4ToBigInt(endIp);
|
||||
if (start === undefined || end === undefined || start > end) return [];
|
||||
|
||||
const cidrs: string[] = [];
|
||||
let current = start;
|
||||
while (current <= end) {
|
||||
let maxBlockSize = current === 0n ? 1n << 32n : current & -current;
|
||||
const remaining = end - current + 1n;
|
||||
while (maxBlockSize > remaining) {
|
||||
maxBlockSize = maxBlockSize / 2n;
|
||||
}
|
||||
const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize);
|
||||
cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`);
|
||||
current += maxBlockSize;
|
||||
}
|
||||
return cidrs;
|
||||
}
|
||||
|
||||
private ipv4ToBigInt(ip: string): bigint | undefined {
|
||||
const normalized = this.normalizeIp(ip);
|
||||
if (!normalized || plugins.net.isIP(normalized) !== 4) return undefined;
|
||||
return normalized
|
||||
.split('.')
|
||||
.reduce((sum, part) => (sum * 256n) + BigInt(Number(part)), 0n);
|
||||
}
|
||||
|
||||
private numberToIpv4(value: bigint): string {
|
||||
return [
|
||||
Number((value >> 24n) & 255n),
|
||||
Number((value >> 16n) & 255n),
|
||||
Number((value >> 8n) & 255n),
|
||||
Number(value & 255n),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
private powerOfTwoExponent(value: bigint): number {
|
||||
let exponent = 0;
|
||||
let remaining = value;
|
||||
while (remaining > 1n) {
|
||||
remaining >>= 1n;
|
||||
exponent++;
|
||||
}
|
||||
return exponent;
|
||||
}
|
||||
|
||||
private isPublicIp(ip: string): boolean {
|
||||
const family = plugins.net.isIP(ip);
|
||||
if (family === 4) {
|
||||
const parts = ip.split('.').map((part) => Number(part));
|
||||
const [a, b] = parts;
|
||||
if (a === 10 || a === 127 || a === 0 || a >= 224) return false;
|
||||
if (a === 100 && b >= 64 && b <= 127) return false;
|
||||
if (a === 169 && b === 254) return false;
|
||||
if (a === 172 && b >= 16 && b <= 31) return false;
|
||||
if (a === 192 && b === 168) return false;
|
||||
return true;
|
||||
}
|
||||
if (family === 6) {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === '::1' || lower === '::') return false;
|
||||
if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private ruleFromDoc(doc: SecurityBlockRuleDoc): ISecurityBlockRule {
|
||||
return {
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
value: doc.value,
|
||||
matchMode: doc.matchMode,
|
||||
enabled: doc.enabled,
|
||||
reason: doc.reason,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
private async writeAudit(action: string, actor: string, details: Record<string, unknown>): Promise<void> {
|
||||
const doc = new SecurityPolicyAuditDoc();
|
||||
doc.id = plugins.uuid.v4();
|
||||
doc.action = action;
|
||||
doc.actor = actor;
|
||||
doc.details = details;
|
||||
doc.createdAt = Date.now();
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
private async notifyPolicyChanged(): Promise<void> {
|
||||
if (this.onPolicyChanged) {
|
||||
await this.onPolicyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,10 @@ export {
|
||||
ThreatCategory,
|
||||
type IScanResult,
|
||||
type IContentScannerOptions
|
||||
} from './classes.contentscanner.js';
|
||||
} from './classes.contentscanner.js';
|
||||
|
||||
export {
|
||||
SecurityPolicyManager,
|
||||
type ISecurityPolicyManagerOptions,
|
||||
type IRemoteIngressFirewallSnapshot,
|
||||
} from './classes.security-policy-manager.js';
|
||||
|
||||
+214
-26
@@ -19,6 +19,10 @@ export interface IVpnManagerConfig {
|
||||
}>;
|
||||
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
||||
onClientChanged?: () => void;
|
||||
/** Called when a live VPN client's real source IP changes. */
|
||||
onClientSourceIpsChanged?: () => void;
|
||||
/** Poll interval for live VPN client real source IP updates. Default: 10 seconds. */
|
||||
clientSourceIpPollIntervalMs?: number;
|
||||
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
|
||||
destinationPolicy?: {
|
||||
default: 'forceTarget' | 'block' | 'allow';
|
||||
@@ -29,7 +33,7 @@ export interface IVpnManagerConfig {
|
||||
/** Compute per-client AllowedIPs based on the client's target profile IDs.
|
||||
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||
* When not set, defaults to [subnet]. */
|
||||
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
|
||||
getClientAllowedIPs?: (targetProfileIds: string[], clientId?: string, sourceIp?: string) => Promise<string[]>;
|
||||
/** Resolve per-client destination allow-list IPs from target profile IDs.
|
||||
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
|
||||
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
|
||||
@@ -57,6 +61,9 @@ export class VpnManager {
|
||||
private serverKeys?: VpnServerKeysDoc;
|
||||
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||
private clientSourceIps = new Map<string, string>();
|
||||
private clientSourceIpPollTimer?: ReturnType<typeof setInterval>;
|
||||
private clientSourceIpRefreshInFlight = false;
|
||||
|
||||
constructor(config: IVpnManagerConfig) {
|
||||
this.config = config;
|
||||
@@ -111,6 +118,7 @@ export class VpnManager {
|
||||
|
||||
const subnet = this.getSubnet();
|
||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||
const serverEndpoint = this.getWireGuardServerEndpoint();
|
||||
|
||||
const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
|
||||
if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
|
||||
@@ -133,21 +141,21 @@ export class VpnManager {
|
||||
: { default: 'forceTarget' as const, target: '127.0.0.1' };
|
||||
|
||||
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
|
||||
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
||||
listenAddr: '127.0.0.1:0', // Required by smartvpn, unused in wireguard-only mode
|
||||
privateKey: this.serverKeys.noisePrivateKey,
|
||||
publicKey: this.serverKeys.noisePublicKey,
|
||||
subnet,
|
||||
dns: this.config.dns,
|
||||
forwardingMode: forwardingMode as any,
|
||||
transportMode: 'all',
|
||||
transportMode: 'wireguard',
|
||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
socketForwardProxyProtocolSource: 'remoteIp',
|
||||
socketForwardProxyProtocolVpnMetadata: true,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
serverEndpoint,
|
||||
clientAllowedIPs: [subnet],
|
||||
// Bridge-specific config
|
||||
...(isBridge ? {
|
||||
@@ -174,6 +182,9 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshClientSourceIps(false);
|
||||
this.startClientSourceIpPolling();
|
||||
|
||||
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||
}
|
||||
|
||||
@@ -181,15 +192,21 @@ export class VpnManager {
|
||||
* Stop the VPN server.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.stopClientSourceIpPolling();
|
||||
if (this.vpnServer) {
|
||||
try {
|
||||
await this.vpnServer.stopServer();
|
||||
} catch {
|
||||
// Ignore stop errors
|
||||
}
|
||||
this.vpnServer.stop();
|
||||
await this.vpnServer.stop();
|
||||
this.vpnServer = undefined;
|
||||
}
|
||||
const hadClientSourceIps = this.clientSourceIps.size > 0;
|
||||
this.clientSourceIps.clear();
|
||||
if (hadClientSourceIps) {
|
||||
this.config.onClientSourceIpsChanged?.();
|
||||
}
|
||||
this.resolvedForwardingMode = undefined;
|
||||
logger.log('info', 'VPN server stopped');
|
||||
}
|
||||
@@ -244,14 +261,11 @@ export class VpnManager {
|
||||
vlanId: doc.vlanId,
|
||||
});
|
||||
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
);
|
||||
}
|
||||
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
|
||||
bundle.wireguardConfig,
|
||||
doc.targetProfileIds || [],
|
||||
doc.clientId,
|
||||
);
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
doc.clientId = bundle.entry.clientId;
|
||||
@@ -292,6 +306,7 @@ export class VpnManager {
|
||||
await this.vpnServer.removeClient(clientId);
|
||||
const doc = this.clients.get(clientId);
|
||||
this.clients.delete(clientId);
|
||||
this.clientSourceIps.delete(clientId);
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
@@ -333,6 +348,7 @@ export class VpnManager {
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
}
|
||||
this.clientSourceIps.delete(clientId);
|
||||
this.config.onClientChanged?.();
|
||||
}
|
||||
|
||||
@@ -381,9 +397,14 @@ export class VpnManager {
|
||||
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
||||
const client = this.clients.get(clientId);
|
||||
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
|
||||
bundle.wireguardConfig,
|
||||
client?.targetProfileIds || [],
|
||||
clientId,
|
||||
);
|
||||
|
||||
// Update persisted entry with new keys (including private key for export/QR)
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.noisePublicKey = bundle.entry.publicKey;
|
||||
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
@@ -414,15 +435,11 @@ export class VpnManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs) {
|
||||
const profileIds = persisted?.targetProfileIds || [];
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
|
||||
config = config.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
);
|
||||
}
|
||||
config = await this.rewriteWireGuardAllowedIPs(
|
||||
config,
|
||||
persisted?.targetProfileIds || [],
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -454,6 +471,107 @@ export class VpnManager {
|
||||
return this.vpnServer.listClients();
|
||||
}
|
||||
|
||||
public getClientSourceIp(clientId: string): string | undefined {
|
||||
return this.clientSourceIps.get(clientId);
|
||||
}
|
||||
|
||||
public getClientSourceIpMap(): Map<string, string> {
|
||||
return new Map(this.clientSourceIps);
|
||||
}
|
||||
|
||||
public async refreshClientSourceIps(notifyOnChange = true): Promise<boolean> {
|
||||
if (!this.vpnServer || this.clientSourceIpRefreshInFlight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.clientSourceIpRefreshInFlight = true;
|
||||
try {
|
||||
const connectedClients = await this.vpnServer.listClients();
|
||||
const nextSourceIps = new Map<string, string>();
|
||||
const wireguardClientIds = new Set<string>();
|
||||
|
||||
for (const connectedClient of connectedClients) {
|
||||
const clientId = connectedClient.registeredClientId || connectedClient.clientId;
|
||||
if (!clientId) continue;
|
||||
if (connectedClient.transportType === 'wireguard') {
|
||||
wireguardClientIds.add(clientId);
|
||||
}
|
||||
|
||||
const sourceIp = VpnManager.normalizeRemoteAddress(connectedClient.remoteAddr);
|
||||
if (sourceIp) {
|
||||
nextSourceIps.set(clientId, sourceIp);
|
||||
}
|
||||
}
|
||||
|
||||
if (wireguardClientIds.size > 0 && typeof (this.vpnServer as any).listWgPeers === 'function') {
|
||||
try {
|
||||
const wgPeers = await this.vpnServer.listWgPeers();
|
||||
const endpointByPublicKey = new Map<string, string>();
|
||||
for (const peer of wgPeers) {
|
||||
const endpointIp = VpnManager.normalizeRemoteAddress(peer.endpoint);
|
||||
if (peer.publicKey && endpointIp) {
|
||||
endpointByPublicKey.set(peer.publicKey, endpointIp);
|
||||
}
|
||||
}
|
||||
|
||||
for (const client of this.clients.values()) {
|
||||
if (nextSourceIps.has(client.clientId)) continue;
|
||||
if (!wireguardClientIds.has(client.clientId)) continue;
|
||||
if (!client.wgPublicKey) continue;
|
||||
const endpointIp = endpointByPublicKey.get(client.wgPublicKey);
|
||||
if (endpointIp) {
|
||||
nextSourceIps.set(client.clientId, endpointIp);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('warn', `VPN: Failed to refresh WireGuard peer endpoints: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sameSourceIpMap(this.clientSourceIps, nextSourceIps)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.clientSourceIps = nextSourceIps;
|
||||
if (notifyOnChange) {
|
||||
this.config.onClientSourceIpsChanged?.();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.log('warn', `VPN: Failed to refresh client source IPs: ${(err as Error).message}`);
|
||||
return false;
|
||||
} finally {
|
||||
this.clientSourceIpRefreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static normalizeRemoteAddress(remoteAddress?: string): string | undefined {
|
||||
const remoteAddressString = remoteAddress?.trim();
|
||||
if (!remoteAddressString) return undefined;
|
||||
|
||||
if (remoteAddressString.startsWith('[')) {
|
||||
const closingBracketIndex = remoteAddressString.indexOf(']');
|
||||
if (closingBracketIndex > 0) {
|
||||
const bracketedIp = remoteAddressString.slice(1, closingBracketIndex);
|
||||
return plugins.net.isIP(bracketedIp) ? bracketedIp : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (plugins.net.isIP(remoteAddressString)) {
|
||||
return remoteAddressString;
|
||||
}
|
||||
|
||||
const lastColonIndex = remoteAddressString.lastIndexOf(':');
|
||||
if (lastColonIndex > -1 && remoteAddressString.indexOf(':') === lastColonIndex) {
|
||||
const host = remoteAddressString.slice(0, lastColonIndex);
|
||||
if (plugins.net.isIP(host)) {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get telemetry for a specific client.
|
||||
*/
|
||||
@@ -515,6 +633,51 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getWireGuardServerEndpoint(): string {
|
||||
const endpoint = this.config.serverEndpoint?.trim();
|
||||
if (!endpoint) {
|
||||
throw new Error('vpnConfig.serverEndpoint is required when VPN is enabled');
|
||||
}
|
||||
if (endpoint.includes('://') || endpoint.includes('/')) {
|
||||
throw new Error('vpnConfig.serverEndpoint must be a host or host:port, not a URL');
|
||||
}
|
||||
|
||||
const host = endpoint.includes(':') ? endpoint.split(':')[0] : endpoint;
|
||||
const lowerHost = host.toLowerCase();
|
||||
if (
|
||||
lowerHost === 'localhost'
|
||||
|| lowerHost === '0.0.0.0'
|
||||
|| lowerHost.startsWith('127.')
|
||||
) {
|
||||
throw new Error('vpnConfig.serverEndpoint must be reachable by VPN clients');
|
||||
}
|
||||
|
||||
return endpoint.includes(':')
|
||||
? endpoint
|
||||
: `${endpoint}:${this.config.wgListenPort ?? 51820}`;
|
||||
}
|
||||
|
||||
private async rewriteWireGuardAllowedIPs(
|
||||
wireguardConfig: string,
|
||||
targetProfileIds: string[],
|
||||
clientId?: string,
|
||||
): Promise<string> {
|
||||
if (!this.config.getClientAllowedIPs) return wireguardConfig;
|
||||
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(
|
||||
targetProfileIds,
|
||||
clientId,
|
||||
clientId ? this.getClientSourceIp(clientId) : undefined,
|
||||
);
|
||||
const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
|
||||
const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`;
|
||||
|
||||
if (/^AllowedIPs\s*=.*$/m.test(wireguardConfig)) {
|
||||
return wireguardConfig.replace(/^AllowedIPs\s*=.*$/m, allowedLine);
|
||||
}
|
||||
return `${wireguardConfig.trimEnd()}\n${allowedLine}\n`;
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
||||
@@ -532,7 +695,7 @@ export class VpnManager {
|
||||
|
||||
const noiseKeys = await tempServer.generateKeypair();
|
||||
const wgKeys = await tempServer.generateWgKeypair();
|
||||
tempServer.stop();
|
||||
await tempServer.stop();
|
||||
|
||||
const doc = stored || new VpnServerKeysDoc();
|
||||
doc.noisePrivateKey = noiseKeys.privateKey;
|
||||
@@ -556,6 +719,31 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
private startClientSourceIpPolling(): void {
|
||||
this.stopClientSourceIpPolling();
|
||||
const pollIntervalMs = Math.max(1000, this.config.clientSourceIpPollIntervalMs ?? 10_000);
|
||||
this.clientSourceIpPollTimer = setInterval(() => {
|
||||
void this.refreshClientSourceIps().catch((err) => {
|
||||
logger.log('warn', `VPN: Client source IP polling failed: ${err?.message || err}`);
|
||||
});
|
||||
}, pollIntervalMs);
|
||||
this.clientSourceIpPollTimer.unref?.();
|
||||
}
|
||||
|
||||
private stopClientSourceIpPolling(): void {
|
||||
if (!this.clientSourceIpPollTimer) return;
|
||||
clearInterval(this.clientSourceIpPollTimer);
|
||||
this.clientSourceIpPollTimer = undefined;
|
||||
}
|
||||
|
||||
private sameSourceIpMap(left: Map<string, string>, right: Map<string, string>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const [clientId, sourceIp] of left) {
|
||||
if (right.get(clientId) !== sourceIp) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
||||
return this.resolvedForwardingMode
|
||||
?? this.forwardingModeOverride
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ConfigManager } from './classes.config.js';
|
||||
import { LogManager } from './classes.logs.js';
|
||||
import { EmailManager } from './classes.email.js';
|
||||
import { RadiusManager } from './classes.radius.js';
|
||||
import { WorkHosterManager } from './classes.workhoster.js';
|
||||
|
||||
export interface IDcRouterApiClientOptions {
|
||||
baseUrl: string;
|
||||
@@ -31,6 +32,7 @@ export class DcRouterApiClient {
|
||||
public logs: LogManager;
|
||||
public emails: EmailManager;
|
||||
public radius: RadiusManager;
|
||||
public workHosters: WorkHosterManager;
|
||||
|
||||
constructor(options: IDcRouterApiClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
@@ -45,6 +47,7 @@ export class DcRouterApiClient {
|
||||
this.logs = new LogManager(this);
|
||||
this.emails = new EmailManager(this);
|
||||
this.radius = new RadiusManager(this);
|
||||
this.workHosters = new WorkHosterManager(this);
|
||||
}
|
||||
|
||||
// =====================
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class WorkHosterManager {
|
||||
constructor(private clientRef: DcRouterApiClient) {}
|
||||
|
||||
public async getCapabilities(): Promise<interfaces.data.IGatewayCapabilities> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetGatewayCapabilities>(
|
||||
'getGatewayCapabilities',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.capabilities;
|
||||
}
|
||||
|
||||
public async getGatewayClientContext(): Promise<interfaces.data.IGatewayClientContext> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetGatewayClientContext>(
|
||||
'getGatewayClientContext',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.context;
|
||||
}
|
||||
|
||||
public async getDomains(): Promise<interfaces.data.IWorkHosterDomain[]> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetWorkHosterDomains>(
|
||||
'getWorkHosterDomains',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.domains;
|
||||
}
|
||||
|
||||
public async syncRoute(options: {
|
||||
ownership: interfaces.data.IWorkAppRouteOwnership;
|
||||
route: interfaces.data.IDcRouterRouteConfig;
|
||||
enabled?: boolean;
|
||||
}): Promise<interfaces.data.IWorkAppRouteSyncResult> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_SyncWorkAppRoute>(
|
||||
'syncWorkAppRoute',
|
||||
this.clientRef.buildRequestPayload({
|
||||
ownership: options.ownership,
|
||||
route: options.route,
|
||||
enabled: options.enabled,
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteRoute(
|
||||
ownership: interfaces.data.IWorkAppRouteOwnership,
|
||||
): Promise<interfaces.data.IWorkAppRouteSyncResult> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_SyncWorkAppRoute>(
|
||||
'syncWorkAppRoute',
|
||||
this.clientRef.buildRequestPayload({
|
||||
ownership,
|
||||
delete: true,
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export { Certificate, CertificateManager, type ICertificateSummary } from './cla
|
||||
export { ApiToken, ApiTokenBuilder, ApiTokenManager } from './classes.apitoken.js';
|
||||
export { RemoteIngress, RemoteIngressBuilder, RemoteIngressManager } from './classes.remoteingress.js';
|
||||
export { Email, EmailManager } from './classes.email.js';
|
||||
export { WorkHosterManager } from './classes.workhoster.js';
|
||||
|
||||
// Read-only managers
|
||||
export { StatsManager } from './classes.stats.js';
|
||||
|
||||
+54
-44
@@ -1,18 +1,18 @@
|
||||
# @serve.zone/dcrouter-apiclient
|
||||
|
||||
Typed, object-oriented client for operating a running dcrouter instance. It wraps the OpsServer `/typedrequest` API in managers and resource classes so your scripts can work with routes, certificates, tokens, remote ingress edges, emails, stats, config, logs, and RADIUS without hand-rolling requests.
|
||||
`@serve.zone/dcrouter-apiclient` is the object-oriented TypeScript client for the dcrouter OpsServer API. It wraps `/typedrequest` calls in managers, builders, and resource classes for routes, certificates, API tokens, remote ingress, email, stats, config, logs, RADIUS, and WorkHoster integrations.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
```
|
||||
|
||||
You can also import the same client through the main package subpath:
|
||||
The same client is also exposed as a subpath of the main package:
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
@@ -27,10 +27,10 @@ const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
|
||||
await client.login('admin', 'admin');
|
||||
await client.login('admin@example.com', 'strong-password');
|
||||
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
console.log('route count', routes.length, 'warnings', warnings.length);
|
||||
console.log(routes.length, warnings.length);
|
||||
|
||||
const route = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
@@ -38,47 +38,46 @@ const route = await client.routes.build()
|
||||
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
|
||||
.save();
|
||||
|
||||
await route.toggle(false);
|
||||
await route.toggle(true);
|
||||
```
|
||||
|
||||
## What the Client Gives You
|
||||
## Authentication
|
||||
|
||||
| Manager | Purpose |
|
||||
| --- | --- |
|
||||
| `client.routes` | List merged routes, create API routes, toggle routes |
|
||||
| `client.certificates` | Inspect certificates and run certificate operations |
|
||||
| `client.apiTokens` | Create, list, toggle, roll, and revoke API tokens |
|
||||
| `client.remoteIngress` | Manage edge registrations, statuses, and connection tokens |
|
||||
| `client.emails` | Inspect email items and trigger resend flows |
|
||||
| `client.stats` | Health, statistics, and operational summaries |
|
||||
| `client.config` | Read the current configuration view |
|
||||
| `client.logs` | Read recent logs and log-related data |
|
||||
| `client.radius` | Manage RADIUS clients, VLANs, and sessions |
|
||||
|
||||
## Authentication Modes
|
||||
|
||||
| Mode | How it works |
|
||||
| --- | --- |
|
||||
| Admin login | Call `login(username, password)` and the returned identity is stored on the client |
|
||||
| API token | Pass `apiToken` in the constructor and it is injected into requests automatically |
|
||||
The client supports persisted-admin session login and API-token authentication. Initial admin creation is a bootstrap flow exposed by the Ops dashboard and raw TypedRequest contracts; after a persisted admin exists, use that account with `login()`.
|
||||
|
||||
```typescript
|
||||
const client = new DcRouterApiClient({
|
||||
const sessionClient = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
apiToken: 'dcr_your_token_here',
|
||||
});
|
||||
await sessionClient.login('admin@example.com', 'strong-password');
|
||||
|
||||
const tokenClient = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
apiToken: 'dcr_token_value',
|
||||
});
|
||||
```
|
||||
|
||||
Important behavior:
|
||||
`baseUrl` is normalized by removing trailing slashes. Requests are sent to `${baseUrl}/typedrequest`. `buildRequestPayload()` injects the current identity and optional API token for manager methods.
|
||||
|
||||
- `baseUrl` is normalized, and the client automatically calls `${baseUrl}/typedrequest`
|
||||
- `buildRequestPayload()` injects the current identity and optional API token for you
|
||||
- system routes can be toggled, but only API routes are meant for edit and delete flows
|
||||
## Manager Map
|
||||
|
||||
## Route Builder Example
|
||||
| Manager | Purpose |
|
||||
| --- | --- |
|
||||
| `client.routes` | List merged routes, build API routes, update/delete API routes, and toggle routes. |
|
||||
| `client.certificates` | Inspect certificate summaries and trigger certificate operations. |
|
||||
| `client.apiTokens` | Create, list, toggle, roll, and revoke API tokens. |
|
||||
| `client.remoteIngress` | Manage edge registrations, statuses, ports, tags, and connection tokens. |
|
||||
| `client.emails` | Inspect received/cached email items and trigger resend flows. |
|
||||
| `client.workHosters` | Manage WorkHoster-facing route/application integration calls. |
|
||||
| `client.stats` | Read health, counters, summaries, and runtime status. |
|
||||
| `client.config` | Read the current configuration view. |
|
||||
| `client.logs` | Read recent log information. |
|
||||
| `client.radius` | Manage RADIUS clients, VLAN mappings, and accounting sessions. |
|
||||
|
||||
## Route Builder
|
||||
|
||||
```typescript
|
||||
const newRoute = await client.routes.build()
|
||||
const route = await client.routes.build()
|
||||
.setName('internal-app')
|
||||
.setMatch({
|
||||
ports: 443,
|
||||
@@ -91,7 +90,7 @@ const newRoute = await client.routes.build()
|
||||
.setEnabled(true)
|
||||
.save();
|
||||
|
||||
await newRoute.update({
|
||||
await route.update({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 3001 }],
|
||||
@@ -99,17 +98,17 @@ await newRoute.update({
|
||||
});
|
||||
```
|
||||
|
||||
## Token and Remote Ingress Example
|
||||
System routes from `config`, `email`, and `dns` origins are designed to be toggled, not edited. Full create/update/delete behavior is for routes with origin `api`.
|
||||
|
||||
## API Tokens and Remote Ingress
|
||||
|
||||
```typescript
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('ci-token')
|
||||
.setName('automation')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.setExpiresInDays(30)
|
||||
.save();
|
||||
|
||||
console.log('copy this once:', token.tokenValue);
|
||||
|
||||
const edge = await client.remoteIngress.build()
|
||||
.setName('edge-eu-1')
|
||||
.setListenPorts([80, 443])
|
||||
@@ -118,20 +117,31 @@ const edge = await client.remoteIngress.build()
|
||||
.save();
|
||||
|
||||
const connectionToken = await edge.getConnectionToken();
|
||||
console.log(connectionToken);
|
||||
console.log(token.tokenValue, connectionToken);
|
||||
```
|
||||
|
||||
## What This Package Does Not Do
|
||||
## What This Package Is Not
|
||||
|
||||
- It does not start dcrouter.
|
||||
- It does not bundle the dashboard.
|
||||
- It does not replace the raw interfaces package when you want low-level TypedRequest contracts.
|
||||
- It does not serve or bundle the Ops dashboard.
|
||||
- It does not replace `@serve.zone/dcrouter-interfaces` when you want raw TypedRequest contracts.
|
||||
|
||||
Use `@serve.zone/dcrouter` to run the server and `@serve.zone/dcrouter-interfaces` for the shared request/data types.
|
||||
Use `@serve.zone/dcrouter` for the server runtime and `@serve.zone/dcrouter-interfaces` for shared request/data types.
|
||||
|
||||
## Development
|
||||
|
||||
This folder is published from the dcrouter monorepo via `tspublish.json` with order `5`.
|
||||
|
||||
Useful source entry points:
|
||||
|
||||
- `index.ts` exports the public client surface.
|
||||
- `classes.dcrouterapiclient.ts` owns authentication and request dispatch.
|
||||
- `classes.route.ts` owns route resources and builders.
|
||||
- `classes.remoteingress.ts`, `classes.apitoken.ts`, `classes.radius.ts`, and the other manager files wrap focused API domains.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export * from './target-profile.js';
|
||||
export * from './vpn.js';
|
||||
export * from './dns-provider.js';
|
||||
export * from './domain.js';
|
||||
export * from './workhoster.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
export * from './email-domain.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -8,17 +8,77 @@ export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||
// Route Management Data Types
|
||||
// ============================================================================
|
||||
|
||||
export type TApiTokenScope =
|
||||
| 'routes:read' | 'routes:write'
|
||||
| 'config:read'
|
||||
| 'tokens:read' | 'tokens:manage'
|
||||
| 'source-profiles:read' | 'source-profiles:write'
|
||||
| 'target-profiles:read' | 'target-profiles:write'
|
||||
| 'targets:read' | 'targets:write'
|
||||
| 'dns-providers:read' | 'dns-providers:write'
|
||||
| 'domains:read' | 'domains:write'
|
||||
| 'dns-records:read' | 'dns-records:write'
|
||||
| 'acme-config:read' | 'acme-config:write';
|
||||
export const apiTokenScopes = [
|
||||
'*',
|
||||
'routes:read',
|
||||
'routes:write',
|
||||
'config:read',
|
||||
'stats:read',
|
||||
'logs:read',
|
||||
'security:read',
|
||||
'security:write',
|
||||
'emails:read',
|
||||
'emails:write',
|
||||
'certificates:read',
|
||||
'certificates:write',
|
||||
'tokens:read',
|
||||
'tokens:manage',
|
||||
'users:read',
|
||||
'users:manage',
|
||||
'source-profiles:read',
|
||||
'source-profiles:write',
|
||||
'target-profiles:read',
|
||||
'target-profiles:write',
|
||||
'targets:read',
|
||||
'targets:write',
|
||||
'dns-providers:read',
|
||||
'dns-providers:write',
|
||||
'domains:read',
|
||||
'domains:write',
|
||||
'dns-records:read',
|
||||
'dns-records:write',
|
||||
'acme-config:read',
|
||||
'acme-config:write',
|
||||
'email-domains:read',
|
||||
'email-domains:write',
|
||||
'remote-ingress:read',
|
||||
'remote-ingress:write',
|
||||
'vpn:read',
|
||||
'vpn:write',
|
||||
'radius:read',
|
||||
'radius:write',
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
] as const;
|
||||
|
||||
export type TApiTokenScope = typeof apiTokenScopes[number];
|
||||
|
||||
export type TGatewayClientType = 'onebox' | 'cloudly' | 'custom';
|
||||
/** @deprecated Use TGatewayClientType. */
|
||||
export type TWorkHosterType = TGatewayClientType;
|
||||
|
||||
export interface IApiTokenPolicy {
|
||||
role: 'admin' | 'gatewayClient' | 'operator';
|
||||
scopes?: TApiTokenScope[];
|
||||
gatewayClient?: {
|
||||
type: TGatewayClientType;
|
||||
id: string;
|
||||
};
|
||||
hostnamePatterns?: string[];
|
||||
allowedRouteTargets?: Array<{
|
||||
host: string;
|
||||
ports: number[];
|
||||
}>;
|
||||
capabilities?: {
|
||||
readDomains?: boolean;
|
||||
readDnsRecords?: boolean;
|
||||
syncRoutes?: boolean;
|
||||
syncDnsRecords?: boolean;
|
||||
requestCertificates?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Source Profile Types (source-side: who can access)
|
||||
@@ -80,6 +140,18 @@ export interface IRouteMetadata {
|
||||
networkTargetName?: string;
|
||||
/** Timestamp of last reference resolution. */
|
||||
lastResolvedAt?: number;
|
||||
/** External route ownership, used by WorkHoster reconciliation. */
|
||||
ownerType?: 'gatewayClient' | 'workhoster' | 'operator' | 'system';
|
||||
gatewayClientType?: TGatewayClientType;
|
||||
gatewayClientId?: string;
|
||||
gatewayClientAppId?: string;
|
||||
/** @deprecated Use gatewayClientType. */
|
||||
workHosterType?: TGatewayClientType;
|
||||
/** @deprecated Use gatewayClientId. */
|
||||
workHosterId?: string;
|
||||
/** @deprecated Use gatewayClientAppId. */
|
||||
workAppId?: string;
|
||||
externalKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +184,7 @@ export interface IApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
policy?: IApiTokenPolicy;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
@@ -145,6 +218,7 @@ export interface IStoredApiToken {
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
scopes: TApiTokenScope[];
|
||||
policy?: IApiTokenPolicy;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { IIpIntelligenceResult } from '@push.rocks/smartnetwork';
|
||||
|
||||
export type TSecurityBlockRuleType = 'ip' | 'cidr' | 'asn' | 'organization';
|
||||
export type TSecurityBlockRuleMatchMode = 'exact' | 'contains';
|
||||
|
||||
export interface IIpIntelligenceRecord extends IIpIntelligenceResult {
|
||||
ipAddress: string;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
updatedAt: number;
|
||||
seenCount: number;
|
||||
}
|
||||
|
||||
export interface ISecurityBlockRule {
|
||||
id: string;
|
||||
type: TSecurityBlockRuleType;
|
||||
value: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
enabled: boolean;
|
||||
reason?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface ISecurityCompiledPolicy {
|
||||
blockedIps: string[];
|
||||
blockedCidrs: string[];
|
||||
}
|
||||
|
||||
export interface ISecurityPolicyAuditEvent {
|
||||
id: string;
|
||||
action: string;
|
||||
actor: string;
|
||||
details: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { IDomain } from './domain.js';
|
||||
import type { IDnsRecord, TDnsRecordType } from './dns-record.js';
|
||||
import type { IApiTokenPolicy, TApiTokenScope, TGatewayClientType } from './route-management.js';
|
||||
|
||||
export interface IGatewayCapabilities {
|
||||
routes: {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
idempotentSync: boolean;
|
||||
};
|
||||
domains: {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
};
|
||||
certificates: {
|
||||
read: boolean;
|
||||
export: boolean;
|
||||
forceRenew: boolean;
|
||||
};
|
||||
email: {
|
||||
domains: boolean;
|
||||
inbound: boolean;
|
||||
outbound: boolean;
|
||||
};
|
||||
remoteIngress: {
|
||||
enabled: boolean;
|
||||
};
|
||||
dns: {
|
||||
authoritative: boolean;
|
||||
providerManaged: boolean;
|
||||
};
|
||||
http3: {
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IGatewayClientContext {
|
||||
role: IApiTokenPolicy['role'];
|
||||
scopes: TApiTokenScope[];
|
||||
gatewayClient?: {
|
||||
type: TGatewayClientType;
|
||||
id: string;
|
||||
};
|
||||
hostnamePatterns: string[];
|
||||
allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']>;
|
||||
capabilities: NonNullable<IApiTokenPolicy['capabilities']>;
|
||||
}
|
||||
|
||||
export interface IGatewayClient {
|
||||
id: string;
|
||||
type: TGatewayClientType;
|
||||
name: string;
|
||||
description?: string;
|
||||
hostnamePatterns: string[];
|
||||
allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']>;
|
||||
capabilities: NonNullable<IApiTokenPolicy['capabilities']>;
|
||||
enabled: boolean;
|
||||
tokenCount?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface IGatewayClientDomain extends IDomain {
|
||||
capabilities: {
|
||||
canCreateSubdomains: boolean;
|
||||
canManageDnsRecords: boolean;
|
||||
canIssueCertificates: boolean;
|
||||
canHostEmail: boolean;
|
||||
};
|
||||
serviceCount?: number;
|
||||
managePath?: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use IGatewayClientDomain. */
|
||||
export type IWorkHosterDomain = IGatewayClientDomain;
|
||||
|
||||
export interface IGatewayClientOwnership {
|
||||
gatewayClientType?: TGatewayClientType;
|
||||
gatewayClientId?: string;
|
||||
appId: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use IGatewayClientOwnership. */
|
||||
export interface IWorkAppRouteOwnership {
|
||||
workHosterType: TGatewayClientType;
|
||||
workHosterId: string;
|
||||
workAppId: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
export interface IGatewayClientRouteSyncResult {
|
||||
success: boolean;
|
||||
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
|
||||
routeId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use IGatewayClientRouteSyncResult. */
|
||||
export type IWorkAppRouteSyncResult = IGatewayClientRouteSyncResult;
|
||||
|
||||
export interface IGatewayClientDnsRecord extends Omit<IDnsRecord, 'type'> {
|
||||
type: TDnsRecordType | 'MISSING';
|
||||
domainName?: string;
|
||||
status: 'active' | 'missing';
|
||||
gatewayClientType: TGatewayClientType;
|
||||
gatewayClientId: string;
|
||||
appId: string;
|
||||
hostname: string;
|
||||
routeId?: string;
|
||||
serviceName?: string;
|
||||
managePath?: string;
|
||||
}
|
||||
|
||||
export interface IGatewayClientMailOwnership {
|
||||
gatewayClientType: TGatewayClientType;
|
||||
gatewayClientId: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
export interface IWorkAppMailOwnership {
|
||||
workHosterType: TGatewayClientType;
|
||||
workHosterId: string;
|
||||
workAppId: string;
|
||||
}
|
||||
|
||||
export interface IWorkAppMailInboundRoute {
|
||||
enabled: boolean;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
preserveHeaders?: boolean;
|
||||
addHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IWorkAppMailIdentity {
|
||||
id: string;
|
||||
externalKey: string;
|
||||
ownership: IWorkAppMailOwnership;
|
||||
address: string;
|
||||
localPart: string;
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
displayName?: string;
|
||||
inbound?: IWorkAppMailInboundRoute;
|
||||
smtp: {
|
||||
enabled: boolean;
|
||||
username: string;
|
||||
};
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface IWorkAppMailCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
host?: string;
|
||||
ports?: {
|
||||
smtp?: number;
|
||||
submission?: number;
|
||||
smtps?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IWorkAppMailIdentitySyncResult {
|
||||
success: boolean;
|
||||
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
|
||||
identity?: IWorkAppMailIdentity;
|
||||
smtpCredentials?: IWorkAppMailCredentials;
|
||||
message?: string;
|
||||
}
|
||||
+37
-22
@@ -1,18 +1,18 @@
|
||||
# @serve.zone/dcrouter-interfaces
|
||||
|
||||
Shared TypeScript contracts for dcrouter's TypedRequest API. Use this package when you want compile-time request/response types and shared data models without pulling in the higher-level client SDK.
|
||||
`@serve.zone/dcrouter-interfaces` contains the shared TypeScript data models and TypedRequest contracts used by dcrouter's OpsServer, dashboard, API client, tests, and external automation.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-interfaces
|
||||
```
|
||||
|
||||
You can also consume the same contracts through the main package subpath:
|
||||
The same contracts are exposed through the main package subpath:
|
||||
|
||||
```typescript
|
||||
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
@@ -22,23 +22,24 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `data` | Shared runtime-shaped data such as identities, routes, stats, domains, DNS records, VPN data, remote ingress data, and email-domain data |
|
||||
| `requests` | TypedRequest request/response contracts for OpsServer endpoints |
|
||||
| `typedrequestInterfaces` | Re-exported helper types from `@api.global/typedrequest-interfaces` |
|
||||
| `data` | Shared runtime-shaped models such as identities, routes, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
|
||||
| `requests` | TypedRequest request/response contracts for OpsServer methods. |
|
||||
| `typedrequestInterfaces` | Helper types re-exported from `@api.global/typedrequest-interfaces` through `plugins.ts`. |
|
||||
|
||||
## API Surface Covered
|
||||
## Covered API Areas
|
||||
|
||||
| Domain | Examples |
|
||||
| Area | Examples |
|
||||
| --- | --- |
|
||||
| Auth | login, logout, identity verification |
|
||||
| Routes | list merged routes, create, update, delete, toggle |
|
||||
| Access | API tokens, source profiles, target profiles, network targets, users |
|
||||
| DNS and domains | providers, domains, DNS records, ACME config |
|
||||
| Email | email operations and email-domain management |
|
||||
| Auth | admin login, first-admin bootstrap status/creation, logout, identity verification, users |
|
||||
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata |
|
||||
| Access | API tokens, source profiles, target profiles, network targets |
|
||||
| DNS and domains | DNS providers, domains, DNS records, ACME config |
|
||||
| Email | email-domain management and email operations |
|
||||
| Edge services | remote ingress, VPN, RADIUS |
|
||||
| Observability | stats, health, logs, configuration |
|
||||
| Observability | stats, combined stats, logs, configuration |
|
||||
| WorkHoster | external app/workhoster route ownership contracts |
|
||||
|
||||
## Quick Example
|
||||
## Raw TypedRequest Example
|
||||
|
||||
```typescript
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
@@ -61,21 +62,35 @@ const request = new TypedRequest<requests.IReq_GetMergedRoutes>(
|
||||
const response = await request.fire({ identity });
|
||||
|
||||
for (const route of response.routes) {
|
||||
console.log(route.id, route.origin, route.systemKey, route.enabled);
|
||||
console.log(route.id, route.origin, route.enabled, route.systemKey);
|
||||
}
|
||||
```
|
||||
|
||||
## When To Use This Package
|
||||
## Bootstrap Contracts
|
||||
|
||||
- Use it in tests that need strong request/response typing.
|
||||
- Use it in custom CLIs or dashboards that call TypedRequest directly.
|
||||
- Use it in shared code where both client and server need the same data shapes.
|
||||
The auth request group includes `getAdminBootstrapStatus` and `createInitialAdminUser`. These exist so a fresh DB-backed dcrouter can require explicit first-admin creation instead of auto-persisting a default account. `createInitialAdminUser` requires the temporary bootstrap identity and can optionally enable `idp.global` authentication for the same normalized local email. The SDK defaults to hosted `https://idp.global`; dcrouter URL settings are overrides only.
|
||||
|
||||
If you want managers, builders, and resource classes instead of raw contracts, use `@serve.zone/dcrouter-apiclient`.
|
||||
## When To Use It
|
||||
|
||||
- Use it in custom CLIs that call dcrouter's TypedRequest API directly.
|
||||
- Use it in tests that need request/response types without the OO client.
|
||||
- Use it in integrations where server and client code need the same data shapes.
|
||||
- Use `@serve.zone/dcrouter-apiclient` instead when you want higher-level managers and builders.
|
||||
|
||||
## Development
|
||||
|
||||
This folder is published from the dcrouter monorepo via `tspublish.json` with order `1`, before the client package that imports it.
|
||||
|
||||
Useful source entry points:
|
||||
|
||||
- `index.ts` exports `data` and `requests` namespaces.
|
||||
- `data/index.ts` groups shared data models.
|
||||
- `requests/index.ts` groups TypedRequest contracts.
|
||||
- `data/route-management.ts` defines route ownership, API token scopes, profiles, and network target shapes.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
export type TAdminLoginAuthSource = 'auto' | 'local' | 'idp.global';
|
||||
|
||||
export interface IAdminUserProjection {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
status?: 'active' | 'disabled';
|
||||
authSources?: Array<'local' | 'idp.global'>;
|
||||
}
|
||||
|
||||
// Admin Login
|
||||
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -10,12 +22,50 @@ export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedreq
|
||||
request: {
|
||||
username: string;
|
||||
password: string;
|
||||
authSource?: TAdminLoginAuthSource;
|
||||
};
|
||||
response: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
}
|
||||
|
||||
// Admin bootstrap status
|
||||
export interface IReq_GetAdminBootstrapStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetAdminBootstrapStatus
|
||||
> {
|
||||
method: 'getAdminBootstrapStatus';
|
||||
request: {};
|
||||
response: {
|
||||
dbEnabled: boolean;
|
||||
dbReady: boolean;
|
||||
hasPersistentAdmin: boolean;
|
||||
needsBootstrap: boolean;
|
||||
ephemeralAdminAvailable: boolean;
|
||||
idpGlobalConfigured: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Create the first persisted admin account. Requires the bootstrap/ephemeral admin identity.
|
||||
export interface IReq_CreateInitialAdminUser extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateInitialAdminUser
|
||||
> {
|
||||
method: 'createInitialAdminUser';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
email: string;
|
||||
name?: string;
|
||||
password: string;
|
||||
enableIdpGlobalAuth?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
user?: IAdminUserProjection;
|
||||
};
|
||||
}
|
||||
|
||||
// Admin Logout
|
||||
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -43,4 +93,4 @@ export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.impl
|
||||
valid: boolean;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IApiTokenInfo, TApiTokenScope } from '../data/route-management.js';
|
||||
import type { IApiTokenInfo, IApiTokenPolicy, TApiTokenScope } from '../data/route-management.js';
|
||||
|
||||
// ============================================================================
|
||||
// API Token Management Endpoints
|
||||
@@ -16,9 +16,11 @@ 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;
|
||||
expiresInDays?: number | null;
|
||||
};
|
||||
response: {
|
||||
@@ -38,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[];
|
||||
@@ -54,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: {
|
||||
@@ -73,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: {
|
||||
@@ -92,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;
|
||||
};
|
||||
|
||||
@@ -28,7 +28,8 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
|
||||
> {
|
||||
method: 'getCertificateOverview';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
certificates: ICertificateInfo[];
|
||||
@@ -50,7 +51,8 @@ export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfa
|
||||
> {
|
||||
method: 'reprovisionCertificate';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
};
|
||||
response: {
|
||||
@@ -66,7 +68,8 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
|
||||
> {
|
||||
method: 'reprovisionCertificateDomain';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
domain: string;
|
||||
forceRenew?: boolean;
|
||||
};
|
||||
@@ -83,7 +86,8 @@ export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.i
|
||||
> {
|
||||
method: 'deleteCertificate';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
@@ -99,7 +103,8 @@ export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.i
|
||||
> {
|
||||
method: 'exportCertificate';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
@@ -124,7 +129,8 @@ export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.i
|
||||
> {
|
||||
method: 'importCertificate';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
cert: {
|
||||
id: string;
|
||||
domainName: string;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -18,4 +18,6 @@ export * from './dns-providers.js';
|
||||
export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domains.js';
|
||||
export * from './email-domains.js';
|
||||
export * from './workhoster.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'createRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
@@ -36,7 +37,8 @@ export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'deleteRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
@@ -54,7 +56,8 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'updateRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
listenPorts?: number[];
|
||||
@@ -77,7 +80,8 @@ export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequest
|
||||
> {
|
||||
method: 'regenerateRemoteIngressSecret';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
@@ -95,7 +99,8 @@ export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'getRemoteIngresses';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
edges: IRemoteIngress[];
|
||||
@@ -111,7 +116,8 @@ export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfa
|
||||
> {
|
||||
method: 'getRemoteIngressStatus';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
statuses: IRemoteIngressStatus[];
|
||||
@@ -128,7 +134,8 @@ export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedreque
|
||||
> {
|
||||
method: 'getRemoteIngressConnectionToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
edgeId: string;
|
||||
hubHost?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type {
|
||||
IIpIntelligenceRecord,
|
||||
ISecurityBlockRule,
|
||||
ISecurityCompiledPolicy,
|
||||
ISecurityPolicyAuditEvent,
|
||||
TSecurityBlockRuleMatchMode,
|
||||
TSecurityBlockRuleType,
|
||||
} from '../data/security-policy.js';
|
||||
|
||||
export interface IReq_ListSecurityBlockRules extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListSecurityBlockRules
|
||||
> {
|
||||
method: 'listSecurityBlockRules';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
rules: ISecurityBlockRule[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateSecurityBlockRule
|
||||
> {
|
||||
method: 'createSecurityBlockRule';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
type: TSecurityBlockRuleType;
|
||||
value: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
reason?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
rule?: ISecurityBlockRule;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateSecurityBlockRule
|
||||
> {
|
||||
method: 'updateSecurityBlockRule';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
value?: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
reason?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
rule?: ISecurityBlockRule;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteSecurityBlockRule
|
||||
> {
|
||||
method: 'deleteSecurityBlockRule';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ListIpIntelligence extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListIpIntelligence
|
||||
> {
|
||||
method: 'listIpIntelligence';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
ipAddresses?: string[];
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
records: IIpIntelligenceRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetCompiledSecurityPolicy extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetCompiledSecurityPolicy
|
||||
> {
|
||||
method: 'getCompiledSecurityPolicy';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
policy: ISecurityCompiledPolicy;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ListSecurityPolicyAudit extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListSecurityPolicyAudit
|
||||
> {
|
||||
method: 'listSecurityPolicyAudit';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
events: ISecurityPolicyAuditEvent[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_RefreshIpIntelligence extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RefreshIpIntelligence
|
||||
> {
|
||||
method: 'refreshIpIntelligence';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
ipAddress: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
record?: IIpIntelligenceRecord;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user