Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2596303c06 | |||
| f78bddaede | |||
| a2887d6266 | |||
| 97505935bb | |||
| 7e3b89d9b4 | |||
| 7bb6559748 | |||
| 5fbe2eb80b | |||
| a22cc1c0eb | |||
| 4ea339b85a | |||
| df9cc3e49b | |||
| 7f3ab2499d | |||
| 89ab918826 | |||
| e5c3578163 | |||
| 1567606c49 | |||
| af31982d58 | |||
| a322308623 | |||
| ec5374900c | |||
| 49ce265d7e | |||
| 63729697c5 | |||
| ce93b726ef | |||
| 1c3aa89f8d | |||
| b3751abd17 | |||
| 97017ede98 | |||
| 4b928b038e | |||
| a466b88408 | |||
| e26ea9e114 | |||
| c5ca95b6f5 | |||
| 1f25ca4095 | |||
| 2891e5d3ee | |||
| 152110c877 | |||
| d780e02928 | |||
| 8bbaf26813 | |||
| 39f449cbe4 | |||
| e0386beb15 | |||
| 1d7e5495fa | |||
| 9a378ae87f | |||
| 58fbc2b1e4 | |||
| 20ea0ce683 | |||
| bcea93753b | |||
| 848515e424 | |||
| 38c9978969 | |||
| ee863b8178 | |||
| 9bb5a8bcc1 | |||
| 5aa07e81c7 | |||
| aec8b72ca3 | |||
| 466654ee4c | |||
| f1a11e3f6a | |||
| e193b3a8eb | |||
| 1bbf31605c | |||
| f2cfa923a0 | |||
| cdc77305e5 | |||
| 835537f789 | |||
| 754b223f62 | |||
| 0a39d50d20 | |||
| de7b9f7ec5 | |||
| bd959464c7 | |||
| 36b629676f | |||
| 19398ea836 | |||
| 4aba8cc353 | |||
| 5fd036eeb6 | |||
| cfcb66f1ee | |||
| 501f4f9de6 | |||
| fa926eb10b | |||
| f2d0a9ec1b | |||
| 035173702d | |||
| 07a3365496 | |||
| 1c4f7dbb11 | |||
| 1fdff79dd0 | |||
| 59b52d08fa | |||
| 2cdc392a40 | |||
| 433047bbf1 | |||
| 0b81c95de2 | |||
| 196e5dfc1b |
@@ -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
|
||||
|
||||
+3
-11
@@ -30,7 +30,7 @@
|
||||
"@git.zone/cli": {
|
||||
"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.",
|
||||
@@ -67,19 +67,11 @@
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@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"],
|
||||
"registryRepoMap": {
|
||||
"code.foss.global": "serve.zone/dcrouter",
|
||||
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
|
||||
"code.foss.global": "serve.zone/dcrouter"
|
||||
},
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Agent Instructions for dcrouter
|
||||
|
||||
## Database & Migrations
|
||||
|
||||
### Collection Names
|
||||
smartdata uses the **exact class name** as the MongoDB collection name. No lowercasing.
|
||||
- `StoredRouteDoc` → collection `StoredRouteDoc`
|
||||
- `TargetProfileDoc` → collection `TargetProfileDoc`
|
||||
- `RouteDoc` → collection `RouteDoc`
|
||||
|
||||
When writing migrations in `ts_migrations/index.ts`, use the exact class name casing in `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
|
||||
|
||||
### Migration Rules
|
||||
- All DB schema migrations go EXCLUSIVELY in `ts_migrations/index.ts` as smartmigration steps.
|
||||
- NEVER put migration logic in application code (services, managers, startup hooks).
|
||||
- Migration step `.to()` version must match the release version so smartmigration can plan the step.
|
||||
- Steps must be idempotent — smartmigration may re-run them in skip-forward resume mode.
|
||||
+9
-6
@@ -1,12 +1,17 @@
|
||||
# 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 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 +23,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"
|
||||
|
||||
+230
@@ -1,5 +1,235 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-09 - 13.27.0 - feat(api-token-manager)
|
||||
seed and rotate the environment-managed admin API token during initialization
|
||||
|
||||
- Add initialization support for DCROUTER_ADMIN_API_TOKEN with validation, persistence, and admin policy assignment
|
||||
- Ensure the environment-managed token is updated when the configured raw token changes
|
||||
- Refactor token hashing into a shared helper and add coverage for seeding, validation, redaction, and rotation behavior
|
||||
|
||||
## 2026-05-09 - 13.26.0 - feat(gateway-clients)
|
||||
add policy-based gateway client tokens and gateway client route and DNS management endpoints
|
||||
|
||||
- Introduces API token policies with admin and gatewayClient roles, capability checks, hostname restrictions, and allowed route targets.
|
||||
- Adds gateway client request and data interfaces for domains, DNS records, route sync, and ownership metadata while keeping workhoster aliases for compatibility.
|
||||
- Extends route metadata normalization to prefer gatewayClient ownership and updates generated route names and test coverage accordingly.
|
||||
|
||||
## 2026-04-26 - 13.25.0 - feat(security)
|
||||
compile network ranges and CIDR arrays into edge firewall policies
|
||||
|
||||
- add support for storing intelligence network CIDR arrays alongside single network ranges
|
||||
- convert start-end IPv4 ranges into CIDR blocks when compiling security policies
|
||||
- always return an explicit remote ingress firewall snapshot with a blockedIps array
|
||||
- add tests covering range normalization, ASN-derived CIDRs, and empty firewall snapshots
|
||||
|
||||
## 2026-04-26 - 13.24.0 - feat(security)
|
||||
add security policy management and IP intelligence operations to the ops UI
|
||||
|
||||
- adds typed request endpoints to fetch compiled security policy, list audit events, and force-refresh IP intelligence
|
||||
- introduces dedicated security policy state and actions for loading, creating, updating, deleting, and refreshing security data
|
||||
- enhances the network activity view with IP intelligence columns, detail dialogs, and block-rule actions
|
||||
- expands the security blocked view into a full management interface for rules, compiled policy, IP intelligence, and audit history
|
||||
|
||||
## 2026-04-26 - 13.23.0 - feat(security)
|
||||
add managed security policies with IP intelligence and remote ingress firewall propagation
|
||||
|
||||
- introduces a SecurityPolicyManager that observes public IPs, stores IP intelligence, compiles block policies, and audits policy changes
|
||||
- adds database documents and shared interfaces for security block rules, IP intelligence records, and security policy audit events
|
||||
- exposes ops/admin request handlers to list IP intelligence and create, update, or delete security block rules
|
||||
- applies merged security policies to SmartProxy and propagates firewall snapshots to remote ingress edges and tunnel synchronization
|
||||
|
||||
## 2026-04-26 - 13.22.0 - feat(remoteingress)
|
||||
add remote ingress performance configuration and expose tunnel transport metrics
|
||||
|
||||
- upgrade @serve.zone/remoteingress to support performance tuning and richer tunnel status data
|
||||
- pass remote ingress performance settings through router startup and config APIs
|
||||
- serialize allowed-edge sync operations and await route update hooks to avoid tunnel sync races
|
||||
- expose UDP listen ports and transport, flow control, queue, and traffic metrics in remote ingress APIs and ops UI
|
||||
|
||||
## 2026-04-26 - 13.21.1 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^27.8.1
|
||||
|
||||
- Updates @push.rocks/smartproxy from ^27.8.0 to ^27.8.1 in package.json.
|
||||
|
||||
## 2026-04-25 - 13.21.0 - feat(monitoring)
|
||||
improve network activity metrics with live domain request rates and backend identifiers
|
||||
|
||||
- use SmartProxy per-domain live request rates to rank and attribute domain activity metrics, while retaining lifetime request totals as fallback data
|
||||
- separate aggregate backend rows from protocol cache rows with stable ids so cached protocol entries no longer duplicate active backend connection counts
|
||||
- expose frontend and backend protocol distributions plus aggregated connectionCount fields through ops and web network views
|
||||
|
||||
## 2026-04-17 - 13.20.2 - fix(vpn)
|
||||
handle VPN forwarding mode downgrades and support runtime VPN config updates
|
||||
|
||||
- restart the VPN server back to socket mode when host-IP clients are removed while preserving explicit hybrid mode
|
||||
- allow DcRouter to update VPN configuration at runtime and refresh route allow-list resolution without recreating the router
|
||||
- improve VPN operations UI target profile rendering and loading behavior for create and edit flows
|
||||
|
||||
## 2026-04-17 - 13.20.1 - fix(docs)
|
||||
refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance
|
||||
|
||||
- Reworks the main README with updated positioning, quick-start examples, route ownership guidance, configuration notes, automation examples, and OCI bootstrap details
|
||||
- Expands package-specific readmes for the runtime, API client, interfaces, migrations, and web dashboard to better describe exports, behavior, and usage
|
||||
- Standardizes documentation references such as subpath import guidance and LICENSE link casing across readmes
|
||||
|
||||
## 2026-04-17 - 13.20.0 - feat(routes)
|
||||
add remote ingress controls and preserve-port targeting for route configuration
|
||||
|
||||
- Allow route updates to remove optional top-level properties by treating null values like remoteIngress as explicit clears.
|
||||
- Add route form support for preserving the matched incoming port when forwarding to backend targets.
|
||||
- Add remote ingress enablement and edge filter controls to route create/edit views.
|
||||
- Cover remoteIngress removal behavior with a runtime route manager test.
|
||||
|
||||
## 2026-04-16 - 13.19.1 - fix(routes)
|
||||
preserve inline target ports when clearing network target references
|
||||
|
||||
- Normalize route metadata so empty reference fields are removed instead of persisted.
|
||||
- Allow the routes UI to clear source profile and network target references explicitly during edits.
|
||||
- Disable inline target host and port inputs when a network target is selected and validate target ports when using manual targets.
|
||||
- Add runtime route tests covering removal of a network target reference while keeping the edited inline target port.
|
||||
|
||||
## 2026-04-15 - 13.19.0 - feat(routes,email)
|
||||
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers
|
||||
|
||||
- Persist seeded DNS-over-HTTPS routes with stable system keys and hydrate socket handlers at runtime instead of treating them as runtime-only routes
|
||||
- Restrict system-managed routes to toggle-only operations across the route manager, Ops API, and web UI while returning explicit mutation errors
|
||||
- Add a shared email DNS record builder and cover email queue operations and handler behavior with new tests
|
||||
|
||||
## 2026-04-14 - 13.18.0 - feat(email)
|
||||
add persistent smartmta storage and runtime-managed email domain syncing
|
||||
|
||||
- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence
|
||||
- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart
|
||||
- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation
|
||||
|
||||
## 2026-04-14 - 13.17.9 - fix(monitoring)
|
||||
align domain activity metrics with id-keyed route data
|
||||
|
||||
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
|
||||
- Add a regression test covering domain activity aggregation for routes identified only by id.
|
||||
- Update the network activity UI to show formatted total connection counts in the active connections card.
|
||||
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
|
||||
|
||||
## 2026-04-14 - 13.17.8 - fix(opsserver)
|
||||
align certificate status handling with the updated smartproxy response format
|
||||
|
||||
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
|
||||
- bump @push.rocks/smartproxy to ^27.7.3
|
||||
- enable verbose output for the test script
|
||||
|
||||
## 2026-04-14 - 13.17.7 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-14 - 13.17.6 - fix(dns,routes)
|
||||
keep DoH socket-handler routes runtime-only and prune stale persisted entries
|
||||
|
||||
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
|
||||
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
|
||||
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
|
||||
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
|
||||
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
|
||||
|
||||
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
|
||||
normalize target profile route references and stabilize VPN host-IP client routing behavior
|
||||
|
||||
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
|
||||
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
|
||||
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
|
||||
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
|
||||
|
||||
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
|
||||
sync route filter toggle selection via component changeSubject
|
||||
|
||||
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
|
||||
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
|
||||
|
||||
## 2026-04-13 - 13.17.2 - fix(monitoring)
|
||||
exclude unconfigured routes from domain activity aggregation
|
||||
|
||||
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
|
||||
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
|
||||
|
||||
## 2026-04-13 - 13.17.1 - fix(monitoring)
|
||||
stop allocating route metrics to domains when no request data exists
|
||||
|
||||
- Removes the equal-split fallback for shared routes in MetricsManager.
|
||||
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
|
||||
|
||||
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
|
||||
add request-based domain activity metrics and split routes into user and system views
|
||||
|
||||
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
|
||||
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
|
||||
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
|
||||
|
||||
## 2026-04-13 - 13.16.2 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^27.6.0
|
||||
|
||||
- updates @push.rocks/smartproxy from ^27.5.0 to ^27.6.0 in package.json
|
||||
|
||||
## 2026-04-13 - 13.16.1 - fix(migrations)
|
||||
use exact smartdata collection names in route unification migration
|
||||
|
||||
- Update the 13.16.0 migration to rename StoredRouteDoc to RouteDoc using case-sensitive collection names
|
||||
- Apply the origin backfill against the RouteDoc collection and drop RouteOverrideDoc with matching class-name casing
|
||||
- Clarify migration description and comments to reflect smartdata's exact class-name collection mapping
|
||||
|
||||
## 2026-04-13 - 13.16.0 - feat(routes)
|
||||
unify route storage and management across config, email, dns, and API origins
|
||||
|
||||
- Persist config-, email-, and dns-seeded routes in the database alongside API-created routes using a single RouteDoc model with origin tracking
|
||||
- Remove hardcoded-route override handling in favor of direct route CRUD and toggle operations by route id across the API client, handlers, and web UI
|
||||
- Add a migration that renames stored route storage, sets migrated routes to origin="api", and drops obsolete route override data
|
||||
|
||||
## 2026-04-13 - 13.15.1 - fix(monitoring)
|
||||
improve domain activity aggregation for multi-domain and wildcard routes
|
||||
|
||||
- map route metrics across all configured domains instead of only the first domain
|
||||
- resolve wildcard domain patterns against active protocol cache entries
|
||||
- distribute shared route traffic across matched domains and preserve fallback reporting for routes without domain configuration
|
||||
|
||||
## 2026-04-13 - 13.15.0 - feat(stats)
|
||||
add typed network stats response fields for bandwidth, domain activity, and protocol distribution
|
||||
|
||||
- extends the network stats request interface with top IP bandwidth, domain activity, and frontend/backend protocol distribution data
|
||||
- updates app state to use a typed getNetworkStats request instead of casting the response to any
|
||||
|
||||
## 2026-04-13 - 13.14.0 - feat(network)
|
||||
add bandwidth-ranked IP and domain activity metrics to network monitoring
|
||||
|
||||
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
|
||||
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
|
||||
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
|
||||
|
||||
## 2026-04-13 - 13.13.0 - feat(dns)
|
||||
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
|
||||
|
||||
- adds domain migration support in DnsManager, API handlers, request interfaces, app state, and domains UI
|
||||
- routes ACME DNS-01 challenges through managed domains using createRecord/deleteRecord for both dcrouter-hosted and provider-managed zones
|
||||
- enables immediate unregister of deleted dcrouter-hosted DNS records from the embedded DNS server
|
||||
|
||||
## 2026-04-12 - 13.12.0 - feat(email-domains)
|
||||
support creating email domains on optional subdomains
|
||||
|
||||
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
|
||||
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
|
||||
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
|
||||
|
||||
## 2026-04-12 - 13.11.0 - feat(email-domains)
|
||||
add email domain management with DNS provisioning, validation, and ops dashboard support
|
||||
|
||||
- Introduce EmailDomainManager with persisted email domain records, DKIM configuration, DNS record generation, provisioning, and validation.
|
||||
- Add opsserver typed request handlers and shared interfaces for listing, creating, updating, deleting, validating, and provisioning email domains.
|
||||
- Add ops dashboard email domains view and app state integration for managing domains and inspecting required DNS records.
|
||||
|
||||
## 2026-04-12 - 13.10.0 - feat(web-ui)
|
||||
standardize settings views for ACME and email security panels
|
||||
|
||||
- replace custom ACME settings layouts with the reusable dees-settings component for configured and empty states
|
||||
- update the email security view to present settings through dees-settings and open a modal-based read-only edit dialog
|
||||
- bump @design.estate/dees-catalog to ^3.78.0 to support the updated UI components
|
||||
|
||||
## 2026-04-12 - 13.9.2 - fix(web-ui)
|
||||
improve form field descriptions and align certificate settings with tile components
|
||||
|
||||
|
||||
+28
-27
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.9.2",
|
||||
"version": "13.27.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||
"start": "(node ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
@@ -23,11 +23,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tsbundle": "^2.10.1",
|
||||
"@git.zone/tsdocker": "^2.2.5",
|
||||
"@git.zone/tsrun": "^2.0.3",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.6.0"
|
||||
"@git.zone/tswatch": "^3.3.3",
|
||||
"@types/node": "^25.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
@@ -35,41 +36,41 @@
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.76.1",
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@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.0",
|
||||
"@push.rocks/smartdns": "^7.9.2",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartjwt": "^2.2.2",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmigration": "1.2.0",
|
||||
"@push.rocks/smartmta": "^5.3.1",
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartmigration": "1.3.1",
|
||||
"@push.rocks/smartmta": "^5.3.3",
|
||||
"@push.rocks/smartnetwork": "^4.7.1",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.5.0",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartproxy": "^27.10.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/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.3",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.5.0",
|
||||
"@serve.zone/remoteingress": "^4.17.1",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.3.3",
|
||||
"lru-cache": "^11.3.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
|
||||
Generated
+1782
-1903
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
+4
-46
@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
||||
},
|
||||
source: 'programmatic',
|
||||
id: 'route-123',
|
||||
enabled: true,
|
||||
overridden: false,
|
||||
storedRouteId: 'route-123',
|
||||
origin: 'api',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
});
|
||||
|
||||
expect(route.name).toEqual('test-route');
|
||||
expect(route.source).toEqual('programmatic');
|
||||
expect(route.id).toEqual('route-123');
|
||||
expect(route.enabled).toEqual(true);
|
||||
expect(route.overridden).toEqual(false);
|
||||
expect(route.storedRouteId).toEqual('route-123');
|
||||
expect(route.origin).toEqual('api');
|
||||
expect(route.routeConfig.match.ports).toEqual(443);
|
||||
});
|
||||
|
||||
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const route = new Route(client, {
|
||||
route: {
|
||||
name: 'hardcoded-route',
|
||||
match: { ports: 80 },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
|
||||
},
|
||||
source: 'hardcoded',
|
||||
enabled: true,
|
||||
overridden: false,
|
||||
// No storedRouteId for hardcoded routes
|
||||
});
|
||||
|
||||
let updateError: Error | undefined;
|
||||
try {
|
||||
await route.update({ name: 'new-name' });
|
||||
} catch (e) {
|
||||
updateError = e as Error;
|
||||
}
|
||||
expect(updateError).toBeTruthy();
|
||||
expect(updateError!.message).toInclude('hardcoded');
|
||||
|
||||
let deleteError: Error | undefined;
|
||||
try {
|
||||
await route.delete();
|
||||
} catch (e) {
|
||||
deleteError = e as Error;
|
||||
}
|
||||
expect(deleteError).toBeTruthy();
|
||||
|
||||
let toggleError: Error | undefined;
|
||||
try {
|
||||
await route.toggle(false);
|
||||
} catch (e) {
|
||||
toggleError = e as Error;
|
||||
}
|
||||
expect(toggleError).toBeTruthy();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Certificate resource class
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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[]) => {
|
||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
adminIdentityGuard: {
|
||||
exec: async () => false,
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
apiTokenManager: makeApiTokenManager(scopes),
|
||||
certificateStatusMap: new Map(),
|
||||
smartProxy: {
|
||||
routeManager: { getRoutes: () => [] },
|
||||
},
|
||||
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('cleanup test db', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await testDb.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
|
||||
// Verify unified email server was initialized
|
||||
expect(router.emailServer).toBeTruthy();
|
||||
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
|
||||
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
|
||||
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
|
||||
|
||||
// Stop the router
|
||||
await router.stop();
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
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 { DnsManager } from '../ts/dns/manager.dns.js';
|
||||
import { logger } from '../ts/logger.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearTestState = async () => {
|
||||
for (const route of await RouteDoc.findAll()) {
|
||||
await route.delete();
|
||||
}
|
||||
for (const domain of await DomainDoc.findAll()) {
|
||||
await domain.delete();
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: { routes: [] },
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
||||
);
|
||||
|
||||
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
||||
|
||||
const persistedRoutes = await RouteDoc.findAll();
|
||||
expect(persistedRoutes.length).toEqual(2);
|
||||
expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
|
||||
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
|
||||
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
|
||||
|
||||
const mergedRoutes = routeManager.getMergedRoutes().routes;
|
||||
expect(mergedRoutes.length).toEqual(2);
|
||||
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
|
||||
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
|
||||
for (const routeSet of appliedRoutes) {
|
||||
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
|
||||
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
|
||||
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(resolveRoute).toBeDefined();
|
||||
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
|
||||
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: { routes: [] },
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
const staleDnsQueryRoute = new RouteDoc();
|
||||
staleDnsQueryRoute.id = 'stale-doh-query';
|
||||
staleDnsQueryRoute.route = {
|
||||
name: 'dns-over-https-dns-query',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['ns1.example.com'],
|
||||
path: '/dns-query',
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
} as any,
|
||||
};
|
||||
staleDnsQueryRoute.enabled = true;
|
||||
staleDnsQueryRoute.createdAt = Date.now();
|
||||
staleDnsQueryRoute.updatedAt = Date.now();
|
||||
staleDnsQueryRoute.createdBy = 'test';
|
||||
staleDnsQueryRoute.origin = 'dns';
|
||||
await staleDnsQueryRoute.save();
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
||||
);
|
||||
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
||||
|
||||
const remainingRoutes = await RouteDoc.findAll();
|
||||
expect(remainingRoutes.length).toEqual(2);
|
||||
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
|
||||
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
|
||||
|
||||
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
|
||||
expect(queryRoute?.id).toEqual('stale-doh-query');
|
||||
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
|
||||
|
||||
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
|
||||
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
expect(appliedRoutes[0].length).toEqual(2);
|
||||
expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager only allows toggling system routes', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const smartProxy = {
|
||||
updateRoutes: async (_routes: any[]) => {
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(() => smartProxy as any);
|
||||
await routeManager.initialize([
|
||||
{
|
||||
name: 'system-config-route',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['app.example.com'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
tls: { mode: 'terminate' as const },
|
||||
},
|
||||
} as any,
|
||||
], [], []);
|
||||
|
||||
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
|
||||
expect(systemRoute).toBeDefined();
|
||||
|
||||
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
|
||||
route: { name: 'renamed-system-route' } as any,
|
||||
});
|
||||
expect(updateResult.success).toEqual(false);
|
||||
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
|
||||
|
||||
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
|
||||
expect(deleteResult.success).toEqual(false);
|
||||
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
|
||||
|
||||
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
|
||||
expect(toggleResult.success).toEqual(true);
|
||||
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager clears a network target ref and keeps the edited inline target port', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const resolver = new ReferenceResolver();
|
||||
(resolver as any).targets.set('target-1', {
|
||||
id: 'target-1',
|
||||
name: 'SSH TARGET',
|
||||
host: '10.0.0.5',
|
||||
port: 443,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
undefined,
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
await routeManager.initialize([], [], []);
|
||||
|
||||
const routeId = await routeManager.createRoute(
|
||||
{
|
||||
name: 'ssh-route',
|
||||
match: { ports: [22] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 22 }],
|
||||
},
|
||||
} as any,
|
||||
'test-user',
|
||||
true,
|
||||
{ networkTargetRef: 'target-1' },
|
||||
);
|
||||
|
||||
expect((await RouteDoc.findById(routeId))?.route.action.targets?.[0].port).toEqual(443);
|
||||
expect((await RouteDoc.findById(routeId))?.metadata?.networkTargetRef).toEqual('target-1');
|
||||
|
||||
const updateResult = await routeManager.updateRoute(routeId, {
|
||||
route: {
|
||||
action: {
|
||||
targets: [{ host: '127.0.0.1', port: 29424 }],
|
||||
},
|
||||
} as any,
|
||||
metadata: {
|
||||
networkTargetRef: '',
|
||||
networkTargetName: '',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(updateResult.success).toEqual(true);
|
||||
|
||||
const storedRoute = await RouteDoc.findById(routeId);
|
||||
expect(storedRoute?.route.action.targets?.[0].host).toEqual('127.0.0.1');
|
||||
expect(storedRoute?.route.action.targets?.[0].port).toEqual(29424);
|
||||
expect(storedRoute?.metadata?.networkTargetRef).toBeUndefined();
|
||||
expect(storedRoute?.metadata?.networkTargetName).toBeUndefined();
|
||||
|
||||
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
|
||||
expect(mergedRoute?.route.action.targets?.[0].port).toEqual(29424);
|
||||
expect(mergedRoute?.metadata?.networkTargetRef).toBeUndefined();
|
||||
expect(mergedRoute?.metadata?.networkTargetName).toBeUndefined();
|
||||
|
||||
expect(appliedRoutes[appliedRoutes.length - 1][0].action.targets[0].port).toEqual(29424);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager clears remote ingress config when route patch sets it to null', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
);
|
||||
await routeManager.initialize([], [], []);
|
||||
|
||||
const routeId = await routeManager.createRoute(
|
||||
{
|
||||
name: 'remote-ingress-route',
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
remoteIngress: {
|
||||
enabled: true,
|
||||
edgeFilter: ['edge-a', 'blue'],
|
||||
},
|
||||
} as any,
|
||||
'test-user',
|
||||
);
|
||||
|
||||
const updateResult = await routeManager.updateRoute(routeId, {
|
||||
route: {
|
||||
remoteIngress: null,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(updateResult.success).toEqual(true);
|
||||
|
||||
const storedRoute = await RouteDoc.findById(routeId);
|
||||
expect(storedRoute?.route.remoteIngress).toBeUndefined();
|
||||
|
||||
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
|
||||
expect(mergedRoute?.route.remoteIngress).toBeUndefined();
|
||||
|
||||
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const originalLog = logger.log.bind(logger);
|
||||
const warningMessages: string[] = [];
|
||||
|
||||
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
|
||||
if (level === 'warn') {
|
||||
warningMessages.push(message);
|
||||
}
|
||||
return originalLog(level, message, context || {});
|
||||
};
|
||||
|
||||
try {
|
||||
const existingDomain = new DomainDoc();
|
||||
existingDomain.id = 'existing-domain';
|
||||
existingDomain.name = 'example.com';
|
||||
existingDomain.source = 'dcrouter';
|
||||
existingDomain.authoritative = true;
|
||||
existingDomain.createdAt = Date.now();
|
||||
existingDomain.updatedAt = Date.now();
|
||||
existingDomain.createdBy = 'test';
|
||||
await existingDomain.save();
|
||||
|
||||
const dnsManager = new DnsManager({
|
||||
dnsNsDomains: ['ns1.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
|
||||
smartProxyConfig: { routes: [] },
|
||||
});
|
||||
|
||||
await dnsManager.start();
|
||||
|
||||
expect(
|
||||
warningMessages.some((message) =>
|
||||
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
|
||||
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
|
||||
),
|
||||
).toEqual(true);
|
||||
expect(
|
||||
warningMessages.some((message) =>
|
||||
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
|
||||
),
|
||||
).toEqual(false);
|
||||
} finally {
|
||||
(logger as any).log = originalLog;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup test db', async () => {
|
||||
await clearTestState();
|
||||
const testDb = await testDbPromise;
|
||||
await testDb.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,65 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { buildEmailDnsRecords } from '../ts/email/index.js';
|
||||
|
||||
tap.test('buildEmailDnsRecords uses the configured mail hostname for MX and includes DKIM when provided', async () => {
|
||||
const records = buildEmailDnsRecords({
|
||||
domain: 'example.com',
|
||||
hostname: 'mail.example.com',
|
||||
selector: 'selector1',
|
||||
dkimValue: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
|
||||
statuses: {
|
||||
mx: 'valid',
|
||||
spf: 'missing',
|
||||
dkim: 'valid',
|
||||
dmarc: 'unchecked',
|
||||
},
|
||||
});
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
type: 'MX',
|
||||
name: 'example.com',
|
||||
value: '10 mail.example.com',
|
||||
status: 'valid',
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: 'example.com',
|
||||
value: 'v=spf1 a mx ~all',
|
||||
status: 'missing',
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: 'selector1._domainkey.example.com',
|
||||
value: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
|
||||
status: 'valid',
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: '_dmarc.example.com',
|
||||
value: 'v=DMARC1; p=none; rua=mailto:dmarc@example.com',
|
||||
status: 'unchecked',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('buildEmailDnsRecords omits DKIM when no value is provided', async () => {
|
||||
const records = buildEmailDnsRecords({
|
||||
domain: 'example.net',
|
||||
hostname: 'smtp.example.net',
|
||||
mxPriority: 20,
|
||||
});
|
||||
|
||||
expect(records.map((record) => record.name)).toEqual([
|
||||
'example.net',
|
||||
'example.net',
|
||||
'_dmarc.example.net',
|
||||
]);
|
||||
expect(records[0].value).toEqual('20 smtp.example.net');
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,193 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { EmailDomainManager } from '../ts/email/index.js';
|
||||
import { DcRouterDb, DomainDoc } from '../ts/db/index.js';
|
||||
import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearTestState = async () => {
|
||||
for (const emailDomain of await EmailDomainDoc.findAll()) {
|
||||
await emailDomain.delete();
|
||||
}
|
||||
for (const domain of await DomainDoc.findAll()) {
|
||||
await domain.delete();
|
||||
}
|
||||
};
|
||||
|
||||
const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => {
|
||||
const doc = new DomainDoc();
|
||||
doc.id = id;
|
||||
doc.name = name;
|
||||
doc.source = source;
|
||||
doc.authoritative = source === 'dcrouter';
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
doc.createdBy = 'test';
|
||||
await doc.save();
|
||||
return doc;
|
||||
};
|
||||
|
||||
const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [
|
||||
{
|
||||
domain: 'static.example.com',
|
||||
dnsMode: 'external-dns',
|
||||
},
|
||||
],
|
||||
routes: [],
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider');
|
||||
const updateCalls: Array<{ domains?: any[] }> = [];
|
||||
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
emailServer: {
|
||||
updateOptions: (options: { domains?: any[] }) => {
|
||||
updateCalls.push(options);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
await manager.start();
|
||||
|
||||
const created = await manager.createEmailDomain({
|
||||
linkedDomainId: linkedDomain.id,
|
||||
subdomain: 'mail',
|
||||
dkimSelector: 'selector1',
|
||||
rotateKeys: true,
|
||||
rotationIntervalDays: 30,
|
||||
});
|
||||
|
||||
const domainsAfterCreate = dcRouterStub.options.emailConfig.domains;
|
||||
expect(domainsAfterCreate.length).toEqual(2);
|
||||
expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true);
|
||||
|
||||
const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com');
|
||||
expect(managedDomain).toBeTruthy();
|
||||
expect(managedDomain?.dnsMode).toEqual('external-dns');
|
||||
expect(managedDomain?.dkim?.selector).toEqual('selector1');
|
||||
expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true);
|
||||
|
||||
await manager.updateEmailDomain(created.id, {
|
||||
rotateKeys: false,
|
||||
rateLimits: {
|
||||
outbound: {
|
||||
messagesPerMinute: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains;
|
||||
const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com');
|
||||
expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false);
|
||||
expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10);
|
||||
|
||||
await manager.deleteEmailDomain(created.id);
|
||||
expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']);
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager rejects domains already present in static config', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider');
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await manager.createEmailDomain({ linkedDomainId: linkedDomain.id });
|
||||
} catch (err: unknown) {
|
||||
error = err as Error;
|
||||
}
|
||||
|
||||
expect(error?.message).toEqual('Email domain already configured for static.example.com');
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter');
|
||||
const stored = new EmailDomainDoc();
|
||||
stored.id = 'managed-email-domain';
|
||||
stored.domain = 'mail.managed.example.com';
|
||||
stored.linkedDomainId = linkedDomain.id;
|
||||
stored.subdomain = 'mail';
|
||||
stored.dkim = {
|
||||
selector: 'default',
|
||||
keySize: 2048,
|
||||
rotateKeys: false,
|
||||
rotationIntervalDays: 90,
|
||||
};
|
||||
stored.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
stored.createdAt = new Date().toISOString();
|
||||
stored.updatedAt = new Date().toISOString();
|
||||
await stored.save();
|
||||
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
await manager.start();
|
||||
|
||||
const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com');
|
||||
expect(managedDomain?.dnsMode).toEqual('internal-dns');
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await clearTestState();
|
||||
await testDb.cleanup();
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,175 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const TEST_PORT = 3201;
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||
const TEST_ADMIN_PASSWORD = 'test-admin-password';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
let removedQueueItemId: string | undefined;
|
||||
let lastEnqueueArgs: any[] | undefined;
|
||||
|
||||
const queueItems = [
|
||||
{
|
||||
id: 'failed-email-1',
|
||||
status: 'failed',
|
||||
attempts: 3,
|
||||
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
|
||||
lastError: '550 mailbox unavailable',
|
||||
processingMode: 'mta',
|
||||
route: undefined,
|
||||
createdAt: new Date('2026-04-14T09:00:00.000Z'),
|
||||
processingResult: {
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.net'],
|
||||
cc: ['copy@example.net'],
|
||||
subject: 'Older message',
|
||||
text: 'hello',
|
||||
headers: { 'x-test': '1' },
|
||||
getMessageId: () => 'message-older',
|
||||
getAttachmentsSize: () => 64,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'delivered-email-1',
|
||||
status: 'delivered',
|
||||
attempts: 1,
|
||||
processingMode: 'mta',
|
||||
route: undefined,
|
||||
createdAt: new Date('2026-04-14T11:00:00.000Z'),
|
||||
processingResult: {
|
||||
email: {
|
||||
from: 'fresh@example.com',
|
||||
to: ['new@example.net'],
|
||||
cc: [],
|
||||
subject: 'Newest message',
|
||||
},
|
||||
html: '<p>newest</p>',
|
||||
text: 'newest',
|
||||
headers: { 'x-fresh': 'true' },
|
||||
getMessageId: () => 'message-newer',
|
||||
getAttachmentsSize: () => 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
|
||||
testDcRouter = new DcRouter({
|
||||
opsServerPort: TEST_PORT,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
testDcRouter.emailServer = {
|
||||
getQueueItems: () => [...queueItems],
|
||||
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
|
||||
getQueueStats: () => ({
|
||||
queueSize: 2,
|
||||
status: {
|
||||
pending: 0,
|
||||
processing: 1,
|
||||
failed: 1,
|
||||
deferred: 1,
|
||||
delivered: 1,
|
||||
},
|
||||
}),
|
||||
deliveryQueue: {
|
||||
enqueue: async (...args: any[]) => {
|
||||
lastEnqueueArgs = args;
|
||||
return 'resent-queue-id';
|
||||
},
|
||||
removeItem: async (id: string) => {
|
||||
removedQueueItemId = id;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin for email API tests', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
BASE_URL,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: TEST_ADMIN_PASSWORD,
|
||||
});
|
||||
|
||||
const responseIdentity = response.identity;
|
||||
expect(responseIdentity).toBeDefined();
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
|
||||
adminIdentity = responseIdentity;
|
||||
expect(adminIdentity.jwt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should return queued emails through the email ops API', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_GetAllEmails>(BASE_URL, 'getAllEmails');
|
||||
const response = await request.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response.emails.map((email) => email.id)).toEqual(['delivered-email-1', 'failed-email-1']);
|
||||
expect(response.emails[0].status).toEqual('delivered');
|
||||
expect(response.emails[1].status).toEqual('bounced');
|
||||
});
|
||||
|
||||
tap.test('should return email detail through the email ops API', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_GetEmailDetail>(BASE_URL, 'getEmailDetail');
|
||||
const response = await request.fire({
|
||||
identity: adminIdentity,
|
||||
emailId: 'failed-email-1',
|
||||
});
|
||||
|
||||
expect(response.email?.toList).toEqual(['recipient@example.net']);
|
||||
expect(response.email?.cc).toEqual(['copy@example.net']);
|
||||
expect(response.email?.rejectionReason).toEqual('550 mailbox unavailable');
|
||||
expect(response.email?.headers).toEqual({ 'x-test': '1' });
|
||||
});
|
||||
|
||||
tap.test('should expose queue status through the stats API', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_GetQueueStatus>(BASE_URL, 'getQueueStatus');
|
||||
const response = await request.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response.queues.length).toEqual(1);
|
||||
expect(response.queues[0].size).toEqual(0);
|
||||
expect(response.queues[0].processing).toEqual(1);
|
||||
expect(response.queues[0].failed).toEqual(1);
|
||||
expect(response.queues[0].retrying).toEqual(1);
|
||||
expect(response.totalItems).toEqual(3);
|
||||
});
|
||||
|
||||
tap.test('should resend failed email through the admin email ops API', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_ResendEmail>(BASE_URL, 'resendEmail');
|
||||
const response = await request.fire({
|
||||
identity: adminIdentity,
|
||||
emailId: 'failed-email-1',
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
expect(response.newQueueId).toEqual('resent-queue-id');
|
||||
expect(removedQueueItemId).toEqual('failed-email-1');
|
||||
expect(lastEnqueueArgs?.[0]).toEqual(queueItems[0].processingResult);
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter after email API tests', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,107 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { EmailOpsHandler } from '../ts/opsserver/handlers/email-ops.handler.js';
|
||||
import { StatsHandler } from '../ts/opsserver/handlers/stats.handler.js';
|
||||
|
||||
const createRouterStub = () => ({
|
||||
addTypedHandler: (_handler: unknown) => {},
|
||||
});
|
||||
|
||||
const queueItems = [
|
||||
{
|
||||
id: 'older-failed',
|
||||
status: 'failed',
|
||||
attempts: 3,
|
||||
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
|
||||
lastError: '550 mailbox unavailable',
|
||||
createdAt: new Date('2026-04-14T09:00:00.000Z'),
|
||||
processingResult: {
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.net'],
|
||||
cc: ['copy@example.net'],
|
||||
subject: 'Older message',
|
||||
text: 'hello',
|
||||
headers: { 'x-test': '1' },
|
||||
getMessageId: () => 'message-older',
|
||||
getAttachmentsSize: () => 64,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'newer-delivered',
|
||||
status: 'delivered',
|
||||
attempts: 1,
|
||||
createdAt: new Date('2026-04-14T11:00:00.000Z'),
|
||||
processingResult: {
|
||||
email: {
|
||||
from: 'fresh@example.com',
|
||||
to: ['new@example.net'],
|
||||
cc: [],
|
||||
subject: 'Newest message',
|
||||
},
|
||||
html: '<p>newest</p>',
|
||||
text: 'newest',
|
||||
headers: { 'x-fresh': 'true' },
|
||||
getMessageId: () => 'message-newer',
|
||||
getAttachmentsSize: () => 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
tap.test('EmailOpsHandler maps queue items using public email server APIs', async () => {
|
||||
const opsHandler = new EmailOpsHandler({
|
||||
viewRouter: createRouterStub(),
|
||||
adminRouter: createRouterStub(),
|
||||
dcRouterRef: {
|
||||
emailServer: {
|
||||
getQueueItems: () => queueItems,
|
||||
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const emails = (opsHandler as any).getAllQueueEmails();
|
||||
expect(emails.map((email: any) => email.id)).toEqual(['newer-delivered', 'older-failed']);
|
||||
expect(emails[0].status).toEqual('delivered');
|
||||
expect(emails[1].status).toEqual('bounced');
|
||||
expect(emails[0].messageId).toEqual('message-newer');
|
||||
|
||||
const detail = (opsHandler as any).getEmailDetail('older-failed');
|
||||
expect(detail?.toList).toEqual(['recipient@example.net']);
|
||||
expect(detail?.cc).toEqual(['copy@example.net']);
|
||||
expect(detail?.rejectionReason).toEqual('550 mailbox unavailable');
|
||||
expect(detail?.headers).toEqual({ 'x-test': '1' });
|
||||
});
|
||||
|
||||
tap.test('StatsHandler reports queue status using public email server APIs', async () => {
|
||||
const statsHandler = new StatsHandler({
|
||||
viewRouter: createRouterStub(),
|
||||
dcRouterRef: {
|
||||
emailServer: {
|
||||
getQueueStats: () => ({
|
||||
queueSize: 2,
|
||||
status: {
|
||||
pending: 0,
|
||||
processing: 1,
|
||||
failed: 1,
|
||||
deferred: 1,
|
||||
delivered: 1,
|
||||
},
|
||||
}),
|
||||
getQueueItems: () => queueItems,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const queueStatus = await (statsHandler as any).getQueueStatus();
|
||||
expect(queueStatus.pending).toEqual(0);
|
||||
expect(queueStatus.active).toEqual(1);
|
||||
expect(queueStatus.failed).toEqual(1);
|
||||
expect(queueStatus.retrying).toEqual(1);
|
||||
expect(queueStatus.items.map((item: any) => item.id)).toEqual(['newer-delivered', 'older-failed']);
|
||||
expect(queueStatus.items[1].nextRetry).toEqual(new Date('2026-04-14T10:00:00.000Z').getTime());
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -103,6 +103,9 @@ tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
if (!(error instanceof PlatformError)) {
|
||||
throw error;
|
||||
}
|
||||
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||
expect(error.context.operation).toEqual('testExecution');
|
||||
}
|
||||
@@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => {
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
expect(error.message).toEqual('Critical error');
|
||||
expect(attempts).toEqual(1); // Should only attempt once
|
||||
}
|
||||
@@ -262,6 +268,9 @@ tap.test('Error handling can be combined with retry for robust operations', asyn
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
expect(error.message).toContain('Flaky failure');
|
||||
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||||
}
|
||||
|
||||
+28
-14
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3102,
|
||||
@@ -25,18 +27,22 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
password: testAdminPassword
|
||||
});
|
||||
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity).toHaveProperty('jwt');
|
||||
expect(response.identity).toHaveProperty('userId');
|
||||
expect(response.identity).toHaveProperty('name');
|
||||
expect(response.identity).toHaveProperty('expiresAt');
|
||||
expect(response.identity).toHaveProperty('role');
|
||||
expect(response.identity.role).toEqual('admin');
|
||||
|
||||
identity = response.identity;
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
expect(responseIdentity).toHaveProperty('jwt');
|
||||
expect(responseIdentity).toHaveProperty('userId');
|
||||
expect(responseIdentity).toHaveProperty('name');
|
||||
expect(responseIdentity).toHaveProperty('expiresAt');
|
||||
expect(responseIdentity).toHaveProperty('role');
|
||||
expect(responseIdentity.role).toEqual('admin');
|
||||
|
||||
identity = responseIdentity;
|
||||
console.log('JWT:', identity.jwt);
|
||||
});
|
||||
|
||||
@@ -53,7 +59,11 @@ tap.test('should verify valid JWT identity', async () => {
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected verify response to include identity');
|
||||
}
|
||||
expect(responseIdentity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
@@ -86,8 +96,12 @@ tap.test('should verify JWT matches identity data', async () => {
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected verify response to include identity');
|
||||
}
|
||||
expect(responseIdentity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(responseIdentity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
@@ -129,4 +143,4 @@ tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
|
||||
|
||||
const emptyProtocolDistribution = {
|
||||
h1Active: 0,
|
||||
h1Total: 0,
|
||||
h2Active: 0,
|
||||
h2Total: 0,
|
||||
h3Active: 0,
|
||||
h3Total: 0,
|
||||
wsActive: 0,
|
||||
wsTotal: 0,
|
||||
otherActive: 0,
|
||||
otherTotal: 0,
|
||||
};
|
||||
|
||||
function createProxyMetrics(args: {
|
||||
connectionsByRoute: Map<string, number>;
|
||||
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||
domainRequestRates?: Map<string, { perSecond: number; lastMinute: number }>;
|
||||
backendMetrics?: Map<string, any>;
|
||||
protocolCache?: any[];
|
||||
requestsTotal?: number;
|
||||
}) {
|
||||
return {
|
||||
connections: {
|
||||
active: () => 0,
|
||||
total: () => 0,
|
||||
byRoute: () => args.connectionsByRoute,
|
||||
byIP: () => new Map<string, number>(),
|
||||
topIPs: () => [],
|
||||
domainRequestsByIP: () => args.domainRequestsByIP,
|
||||
topDomainRequests: () => [],
|
||||
frontendProtocols: () => emptyProtocolDistribution,
|
||||
backendProtocols: () => emptyProtocolDistribution,
|
||||
},
|
||||
throughput: {
|
||||
instant: () => ({ in: 0, out: 0 }),
|
||||
recent: () => ({ in: 0, out: 0 }),
|
||||
average: () => ({ in: 0, out: 0 }),
|
||||
custom: () => ({ in: 0, out: 0 }),
|
||||
history: () => [],
|
||||
byRoute: () => args.throughputByRoute,
|
||||
byIP: () => new Map<string, { in: number; out: number }>(),
|
||||
},
|
||||
requests: {
|
||||
perSecond: () => 0,
|
||||
perMinute: () => 0,
|
||||
total: () => args.requestsTotal || 0,
|
||||
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
|
||||
},
|
||||
totals: {
|
||||
bytesIn: () => 0,
|
||||
bytesOut: () => 0,
|
||||
connections: () => 0,
|
||||
},
|
||||
backends: {
|
||||
byBackend: () => args.backendMetrics || new Map<string, any>(),
|
||||
protocols: () => new Map<string, string>(),
|
||||
topByErrors: () => [],
|
||||
detectedProtocols: () => args.protocolCache || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map([
|
||||
['route-id-only', 4],
|
||||
]),
|
||||
throughputByRoute: new Map([
|
||||
['route-id-only', { in: 1200, out: 2400 }],
|
||||
]),
|
||||
domainRequestsByIP: new Map([
|
||||
['192.0.2.10', new Map([
|
||||
['alpha.example.com', 3],
|
||||
['beta.example.com', 1],
|
||||
])],
|
||||
]),
|
||||
requestsTotal: 4,
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: {
|
||||
getRoutes: () => [
|
||||
{
|
||||
id: 'route-id-only',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['alpha.example.com', 'beta.example.com'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new MetricsManager({ smartProxy } as any);
|
||||
const stats = await manager.getNetworkStats();
|
||||
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
|
||||
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
|
||||
|
||||
expect(alpha).toBeDefined();
|
||||
expect(beta).toBeDefined();
|
||||
|
||||
expect(alpha!.requestCount).toEqual(3);
|
||||
expect(alpha!.routeCount).toEqual(1);
|
||||
expect(alpha!.activeConnections).toEqual(3);
|
||||
expect(alpha!.bytesInPerSecond).toEqual(900);
|
||||
expect(alpha!.bytesOutPerSecond).toEqual(1800);
|
||||
|
||||
expect(beta!.requestCount).toEqual(1);
|
||||
expect(beta!.routeCount).toEqual(1);
|
||||
expect(beta!.activeConnections).toEqual(1);
|
||||
expect(beta!.bytesInPerSecond).toEqual(300);
|
||||
expect(beta!.bytesOutPerSecond).toEqual(600);
|
||||
});
|
||||
|
||||
tap.test('MetricsManager prefers live domain request rates for current activity', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map([
|
||||
['route-id-only', 10],
|
||||
]),
|
||||
throughputByRoute: new Map([
|
||||
['route-id-only', { in: 1000, out: 1000 }],
|
||||
]),
|
||||
domainRequestsByIP: new Map([
|
||||
['192.0.2.10', new Map([
|
||||
['alpha.example.com', 1000],
|
||||
['beta.example.com', 1],
|
||||
])],
|
||||
]),
|
||||
domainRequestRates: new Map([
|
||||
['alpha.example.com', { perSecond: 0, lastMinute: 0 }],
|
||||
['beta.example.com', { perSecond: 5, lastMinute: 60 }],
|
||||
]),
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: {
|
||||
getRoutes: () => [
|
||||
{
|
||||
id: 'route-id-only',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['alpha.example.com', 'beta.example.com'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new MetricsManager({ smartProxy } as any);
|
||||
const stats = await manager.getNetworkStats();
|
||||
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
|
||||
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
|
||||
|
||||
expect(alpha!.activeConnections).toEqual(0);
|
||||
expect(alpha!.requestsPerSecond).toEqual(0);
|
||||
expect(beta!.activeConnections).toEqual(10);
|
||||
expect(beta!.requestsPerSecond).toEqual(5);
|
||||
expect(beta!.bytesInPerSecond).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('MetricsManager does not duplicate backend active counts onto protocol cache rows', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map(),
|
||||
throughputByRoute: new Map(),
|
||||
domainRequestsByIP: new Map(),
|
||||
backendMetrics: new Map([
|
||||
['192.0.2.1:443', {
|
||||
protocol: 'h2',
|
||||
activeConnections: 257,
|
||||
totalConnections: 1000,
|
||||
connectErrors: 1,
|
||||
handshakeErrors: 2,
|
||||
requestErrors: 3,
|
||||
avgConnectTimeMs: 4,
|
||||
poolHitRate: 0.9,
|
||||
h2Failures: 5,
|
||||
}],
|
||||
]),
|
||||
protocolCache: [
|
||||
{
|
||||
host: '192.0.2.1',
|
||||
port: 443,
|
||||
domain: 'alpha.example.com',
|
||||
protocol: 'h2',
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
ageSecs: 1,
|
||||
},
|
||||
{
|
||||
host: '192.0.2.1',
|
||||
port: 443,
|
||||
domain: 'beta.example.com',
|
||||
protocol: 'h2',
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
ageSecs: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: {
|
||||
getRoutes: () => [],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new MetricsManager({ smartProxy } as any);
|
||||
const stats = await manager.getNetworkStats();
|
||||
const aggregate = stats.backends.find((item) => item.id === 'backend:192.0.2.1:443');
|
||||
const cacheRows = stats.backends.filter((item) => item.id?.startsWith('cache:'));
|
||||
|
||||
expect(aggregate!.activeConnections).toEqual(257);
|
||||
expect(cacheRows.length).toEqual(2);
|
||||
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
|
||||
});
|
||||
|
||||
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,11 +27,15 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
password: testAdminPassword,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
});
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
|
||||
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3103,
|
||||
@@ -25,11 +27,15 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
password: testAdminPassword
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
console.log('Admin logged in with JWT');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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('cleanup security policy test db', async () => {
|
||||
const dbHandle = await testDbPromise;
|
||||
await clearTestState();
|
||||
await dbHandle.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SmartMtaStorageManager } from '../ts/email/index.js';
|
||||
|
||||
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test-smartmta-storage');
|
||||
|
||||
tap.test('SmartMtaStorageManager persists, lists, and deletes keys', async () => {
|
||||
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
const storageManager = new SmartMtaStorageManager(tempDir);
|
||||
await storageManager.set('/email/dkim/example.com/default/metadata', 'metadata');
|
||||
await storageManager.set('/email/dkim/example.com/default/public.key', 'public');
|
||||
|
||||
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toEqual('metadata');
|
||||
|
||||
const keys = await storageManager.list('/email/dkim/example.com/');
|
||||
expect(keys).toEqual([
|
||||
'/email/dkim/example.com/default/metadata',
|
||||
'/email/dkim/example.com/default/public.key',
|
||||
]);
|
||||
|
||||
await storageManager.delete('/email/dkim/example.com/default/metadata');
|
||||
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -5,6 +5,7 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const TEST_PORT = 3200;
|
||||
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||
const TEST_ADMIN_PASSWORD = 'test-admin-password';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
@@ -14,6 +15,7 @@ let adminIdentity: interfaces.data.IIdentity;
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
|
||||
testDcRouter = new DcRouter({
|
||||
opsServerPort: TEST_PORT,
|
||||
dbConfig: { enabled: false },
|
||||
@@ -31,11 +33,15 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
password: TEST_ADMIN_PASSWORD,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
|
||||
|
||||
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
|
||||
const manager = new VpnManager({ forwardingMode: 'socket' });
|
||||
|
||||
let stopCalls = 0;
|
||||
let startCalls = 0;
|
||||
|
||||
(manager as any).vpnServer = { running: true };
|
||||
(manager as any).resolvedForwardingMode = 'hybrid';
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { useHostIp: false }],
|
||||
]);
|
||||
(manager as any).stop = async () => {
|
||||
stopCalls++;
|
||||
};
|
||||
(manager as any).start = async () => {
|
||||
startCalls++;
|
||||
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
|
||||
(manager as any).forwardingModeOverride = undefined;
|
||||
(manager as any).vpnServer = { running: true };
|
||||
};
|
||||
|
||||
const restarted = await (manager as any).reconcileForwardingMode();
|
||||
|
||||
expect(restarted).toEqual(true);
|
||||
expect(stopCalls).toEqual(1);
|
||||
expect(startCalls).toEqual(1);
|
||||
expect((manager as any).resolvedForwardingMode).toEqual('socket');
|
||||
});
|
||||
|
||||
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
|
||||
const manager = new VpnManager({ forwardingMode: 'hybrid' });
|
||||
|
||||
let stopCalls = 0;
|
||||
let startCalls = 0;
|
||||
|
||||
(manager as any).vpnServer = { running: true };
|
||||
(manager as any).resolvedForwardingMode = 'hybrid';
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { useHostIp: false }],
|
||||
]);
|
||||
(manager as any).stop = async () => {
|
||||
stopCalls++;
|
||||
};
|
||||
(manager as any).start = async () => {
|
||||
startCalls++;
|
||||
};
|
||||
|
||||
const restarted = await (manager as any).reconcileForwardingMode();
|
||||
|
||||
expect(restarted).toEqual(false);
|
||||
expect(stopCalls).toEqual(0);
|
||||
expect(startCalls).toEqual(0);
|
||||
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
|
||||
});
|
||||
|
||||
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
|
||||
const dcRouter = new DcRouter({
|
||||
smartProxyConfig: { routes: [] },
|
||||
dbConfig: { enabled: false },
|
||||
vpnConfig: { enabled: false },
|
||||
});
|
||||
|
||||
let stopCalls = 0;
|
||||
let setupCalls = 0;
|
||||
let applyCalls = 0;
|
||||
const resolverValues: Array<unknown> = [];
|
||||
|
||||
dcRouter.vpnManager = {
|
||||
stop: async () => {
|
||||
stopCalls++;
|
||||
},
|
||||
} as any;
|
||||
(dcRouter as any).routeConfigManager = {
|
||||
setVpnClientIpsResolver: (resolver: unknown) => {
|
||||
resolverValues.push(resolver);
|
||||
},
|
||||
applyRoutes: async () => {
|
||||
applyCalls++;
|
||||
},
|
||||
};
|
||||
(dcRouter as any).setupVpnServer = async () => {
|
||||
setupCalls++;
|
||||
dcRouter.vpnManager = {
|
||||
stop: async () => {
|
||||
stopCalls++;
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
|
||||
|
||||
expect(stopCalls).toEqual(1);
|
||||
expect(setupCalls).toEqual(1);
|
||||
expect(applyCalls).toEqual(0);
|
||||
expect(typeof resolverValues.at(-1)).toEqual('function');
|
||||
|
||||
await dcRouter.updateVpnConfig({ enabled: false });
|
||||
|
||||
expect(stopCalls).toEqual(2);
|
||||
expect(setupCalls).toEqual(1);
|
||||
expect(applyCalls).toEqual(1);
|
||||
expect(resolverValues.at(-1)).toBeUndefined();
|
||||
expect(dcRouter.vpnManager).toBeUndefined();
|
||||
});
|
||||
|
||||
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,396 @@
|
||||
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[]) => {
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'workhoster-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) => {
|
||||
const scopes = new Set(storedToken.scopes);
|
||||
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[];
|
||||
dcRouterRef?: Record<string, any>;
|
||||
}) => {
|
||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
adminIdentityGuard: {
|
||||
exec: async () => false,
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
apiTokenManager: makeApiTokenManager(options.scopes),
|
||||
...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 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.9.2',
|
||||
version: '13.27.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+437
-214
@@ -9,6 +9,7 @@ import {
|
||||
type IUnifiedEmailServerOptions,
|
||||
type IEmailRoute,
|
||||
type IEmailDomainConfig,
|
||||
type IStorageManagerLike,
|
||||
} from '@push.rocks/smartmta';
|
||||
import { logger } from './logger.js';
|
||||
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
||||
@@ -25,10 +26,14 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
|
||||
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -174,6 +179,8 @@ export interface IDcRouterOptions {
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
};
|
||||
/** Performance profile and limits for remote ingress hub/edge tunnels. */
|
||||
performance?: import('../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -247,15 +254,13 @@ export class DcRouter {
|
||||
public radiusServer?: RadiusServer;
|
||||
public opsServer!: OpsServer;
|
||||
public metricsManager?: MetricsManager;
|
||||
private emailEventSubscriptions: Array<{
|
||||
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
|
||||
eventName: string;
|
||||
listener: (...args: any[]) => void;
|
||||
}> = [];
|
||||
|
||||
// Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set()
|
||||
public storageManager: any = {
|
||||
get: async (_key: string) => null,
|
||||
set: async (_key: string, _value: string) => {
|
||||
// DKIM keys from smartmta — logged but not yet migrated to smartdata
|
||||
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
|
||||
},
|
||||
};
|
||||
public storageManager: IStorageManagerLike;
|
||||
|
||||
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
||||
public dcRouterDb?: DcRouterDb;
|
||||
@@ -279,6 +284,9 @@ 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;
|
||||
@@ -310,8 +318,12 @@ export class DcRouter {
|
||||
// TypedRouter for API endpoints
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
|
||||
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
||||
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
|
||||
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Environment access
|
||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
@@ -324,6 +336,11 @@ export class DcRouter {
|
||||
|
||||
// Resolve all data paths from baseDir
|
||||
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
||||
paths.ensureDataDirectories(this.resolvedPaths);
|
||||
this.storageManager = new SmartMtaStorageManager(
|
||||
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
||||
);
|
||||
this.workAppMailManager = new WorkAppMailManager(this);
|
||||
|
||||
// Initialize service manager and register all services
|
||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||
@@ -439,12 +456,55 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// Email Domain Manager: optional, depends on DcRouterDb
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('EmailDomainManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.emailDomainManager = new EmailDomainManager(this);
|
||||
await this.emailDomainManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.emailDomainManager) {
|
||||
await this.emailDomainManager.stop();
|
||||
this.emailDomainManager = undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence
|
||||
// and compiles the global block policy for SmartProxy and remote ingress edges.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SecurityPolicyManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.securityPolicyManager = new SecurityPolicyManager({
|
||||
onPolicyChanged: () => this.applySecurityPolicy(),
|
||||
});
|
||||
await this.securityPolicyManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.securityPolicyManager) {
|
||||
await this.securityPolicyManager.stop();
|
||||
this.securityPolicyManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||
const smartProxyDeps: string[] = [];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
smartProxyDeps.push('DcRouterDb');
|
||||
smartProxyDeps.push('DnsManager');
|
||||
smartProxyDeps.push('AcmeConfigManager');
|
||||
smartProxyDeps.push('SecurityPolicyManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartProxy')
|
||||
@@ -528,39 +588,41 @@ export class DcRouter {
|
||||
await this.referenceResolver.initialize();
|
||||
|
||||
// Initialize target profile manager
|
||||
this.targetProfileManager = new TargetProfileManager();
|
||||
this.targetProfileManager = new TargetProfileManager(
|
||||
() => this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
await this.targetProfileManager.initialize();
|
||||
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.options.vpnConfig?.enabled
|
||||
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
|
||||
if (!this.vpnManager || !this.targetProfileManager) {
|
||||
// VPN not ready yet — deny all until re-apply after VPN starts
|
||||
return [];
|
||||
}
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route, routeId, this.vpnManager.listClients(),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
this.createVpnRouteAllowListResolver(),
|
||||
this.referenceResolver,
|
||||
// Sync merged routes to RemoteIngressManager whenever routes change,
|
||||
// Sync routes to RemoteIngressManager whenever routes change,
|
||||
// then push updated derived ports to the Rust hub binary
|
||||
(routes) => {
|
||||
async (routes) => {
|
||||
if (this.remoteIngressManager) {
|
||||
this.remoteIngressManager.setRoutes(routes as any[]);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
this.tunnelManager.syncAllowedEdges();
|
||||
try {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
} catch (err: unknown) {
|
||||
logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
await this.apiTokenManager.initialize();
|
||||
await this.routeConfigManager.initialize();
|
||||
await this.routeConfigManager.initialize(
|
||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
);
|
||||
await this.targetProfileManager.normalizeAllRouteRefs();
|
||||
|
||||
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||
const seeder = new DbSeeder(this.referenceResolver);
|
||||
@@ -581,19 +643,20 @@ export class DcRouter {
|
||||
|
||||
// Email Server: optional, depends on SmartProxy
|
||||
if (this.options.emailConfig) {
|
||||
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
emailServiceDeps.push('EmailDomainManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('EmailServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn(...emailServiceDeps)
|
||||
.withStart(async () => {
|
||||
await this.setupUnifiedEmailHandling();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.emailServer) {
|
||||
if ((this.emailServer as any).deliverySystem) {
|
||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||
}
|
||||
this.emailServer.removeAllListeners();
|
||||
this.clearEmailEventSubscriptions();
|
||||
await this.emailServer.stop();
|
||||
this.emailServer = undefined;
|
||||
}
|
||||
@@ -607,7 +670,7 @@ export class DcRouter {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('DnsServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
|
||||
.withStart(async () => {
|
||||
await this.setupDnsWithSocketHandler();
|
||||
})
|
||||
@@ -864,31 +927,32 @@ export class DcRouter {
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
|
||||
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Assemble serializable seed routes from constructor config — these will be seeded into DB
|
||||
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
|
||||
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
|
||||
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
|
||||
|
||||
// If user provides full SmartProxy config, use its routes.
|
||||
// NOTE: `smartProxyConfig.acme` is now seed-only — consumed by
|
||||
// AcmeConfigManager on first boot. The live ACME config always comes
|
||||
// from the DB via `this.acmeConfigManager.getConfig()`.
|
||||
if (this.options.smartProxyConfig) {
|
||||
routes = this.options.smartProxyConfig.routes || [];
|
||||
logger.log('info', `Found ${routes.length} routes in config`);
|
||||
}
|
||||
|
||||
// If email config exists, automatically add email routes
|
||||
this.seedEmailRoutes = [];
|
||||
if (this.options.emailConfig) {
|
||||
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
||||
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
||||
this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
||||
}
|
||||
|
||||
// If DNS is configured, add DNS routes
|
||||
this.seedDnsRoutes = [];
|
||||
this.runtimeDnsRoutes = [];
|
||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||
const dnsRoutes = this.generateDnsRoutes();
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
||||
routes = [...routes, ...dnsRoutes];
|
||||
this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false });
|
||||
this.runtimeDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: true });
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
|
||||
}
|
||||
|
||||
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
||||
let routes: plugins.smartproxy.IRouteConfig[] = [
|
||||
...this.seedConfigRoutes,
|
||||
...this.seedEmailRoutes,
|
||||
...this.runtimeDnsRoutes,
|
||||
];
|
||||
|
||||
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
||||
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
|
||||
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
|
||||
@@ -913,15 +977,16 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
||||
// ACME is enabled. The DnsManager dispatches each challenge to the right
|
||||
// provider client based on the FQDN being certificated.
|
||||
// ACME is enabled. The DnsManager dispatches each challenge through the
|
||||
// unified createRecord()/deleteRecord() path — works for both dcrouter-hosted
|
||||
// zones and provider-managed zones. Only domains under management get certs.
|
||||
let challengeHandlers: any[] = [];
|
||||
if (
|
||||
acmeConfig &&
|
||||
this.dnsManager &&
|
||||
(await this.dnsManager.hasAcmeCapableProvider())
|
||||
(await this.dnsManager.hasAnyManagedDomain())
|
||||
) {
|
||||
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
|
||||
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (managed domains)');
|
||||
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
||||
challengeHandlers.push(dns01Handler);
|
||||
@@ -934,9 +999,11 @@ export class DcRouter {
|
||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||
}
|
||||
|
||||
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
|
||||
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
|
||||
this.constructorRoutes = [...routes];
|
||||
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) {
|
||||
@@ -969,6 +1036,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();
|
||||
@@ -1093,7 +1161,12 @@ export class DcRouter {
|
||||
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
|
||||
if (this.options.remoteIngressConfig?.enabled) {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
}
|
||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||
}
|
||||
}
|
||||
|
||||
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||
@@ -1206,8 +1279,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
|
||||
@@ -1305,19 +1430,20 @@ export class DcRouter {
|
||||
/**
|
||||
* Generate SmartProxy routes for DNS configuration
|
||||
*/
|
||||
private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] {
|
||||
private generateDnsRoutes(options?: { includeSocketHandler?: boolean }): plugins.smartproxy.IRouteConfig[] {
|
||||
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const includeSocketHandler = options?.includeSocketHandler !== false;
|
||||
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
|
||||
// Create routes for DNS-over-HTTPS paths
|
||||
const dohPaths = ['/dns-query', '/resolve'];
|
||||
|
||||
|
||||
// Use the first nameserver domain for DoH routes
|
||||
const primaryNameserver = this.options.dnsNsDomains[0];
|
||||
|
||||
|
||||
for (const path of dohPaths) {
|
||||
const dohRoute: plugins.smartproxy.IRouteConfig = {
|
||||
name: `dns-over-https-${path.replace('/', '')}`,
|
||||
@@ -1326,18 +1452,42 @@ export class DcRouter {
|
||||
domains: [primaryNameserver],
|
||||
path: path
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
socketHandler: this.createDnsSocketHandler()
|
||||
} as any
|
||||
action: includeSocketHandler
|
||||
? {
|
||||
type: 'socket-handler' as any,
|
||||
socketHandler: this.createDnsSocketHandler()
|
||||
} as any
|
||||
: {
|
||||
type: 'socket-handler' as any,
|
||||
} as any
|
||||
};
|
||||
|
||||
|
||||
dnsRoutes.push(dohRoute);
|
||||
}
|
||||
|
||||
|
||||
return dnsRoutes;
|
||||
}
|
||||
|
||||
private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
|
||||
const routeName = storedRoute.route.name || '';
|
||||
const isDohRoute = storedRoute.origin === 'dns'
|
||||
&& storedRoute.route.action?.type === 'socket-handler'
|
||||
&& routeName.startsWith('dns-over-https-');
|
||||
|
||||
if (!isDohRoute) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...storedRoute.route,
|
||||
action: {
|
||||
...storedRoute.route.action,
|
||||
type: 'socket-handler' as any,
|
||||
socketHandler: this.createDnsSocketHandler(),
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain matches a pattern (including wildcard support)
|
||||
* @param domain The domain to check
|
||||
@@ -1388,14 +1538,6 @@ export class DcRouter {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the routes derived from constructor config (smartProxy + email + DNS).
|
||||
* Used by RouteConfigManager as the "hardcoded" base.
|
||||
*/
|
||||
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
|
||||
return this.constructorRoutes;
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
logger.log('info', 'Stopping DcRouter services...');
|
||||
|
||||
@@ -1439,17 +1581,15 @@ export class DcRouter {
|
||||
// Update configuration
|
||||
this.options.smartProxyConfig = config;
|
||||
|
||||
// Update routes on RemoteIngressManager so derived ports stay in sync
|
||||
if (this.remoteIngressManager && config.routes) {
|
||||
this.remoteIngressManager.setRoutes(config.routes as any[]);
|
||||
}
|
||||
|
||||
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
||||
// Start new SmartProxy with updated configuration (rebuilds seed routes)
|
||||
await this.setupSmartProxy();
|
||||
|
||||
// Re-apply programmatic routes and overrides after SmartProxy restart
|
||||
// Re-seed and re-apply all routes after SmartProxy restart
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.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[],
|
||||
);
|
||||
}
|
||||
|
||||
logger.log('info', 'SmartProxy configuration updated');
|
||||
@@ -1492,44 +1632,78 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// Create config with mapped ports
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
|
||||
...this.options.emailConfig,
|
||||
domains: transformedDomains,
|
||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
||||
hostname: 'localhost' // Listen on localhost for SmartProxy forwarding
|
||||
};
|
||||
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
|
||||
queue: {
|
||||
storageType: 'disk',
|
||||
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
||||
...this.options.emailConfig.queue,
|
||||
},
|
||||
});
|
||||
|
||||
// Create unified email server
|
||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||
this.clearEmailEventSubscriptions();
|
||||
|
||||
// Set up error handling
|
||||
this.emailServer.on('error', (err: Error) => {
|
||||
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
|
||||
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await this.emailServer.start();
|
||||
|
||||
// Wire delivery events to MetricsManager and logger
|
||||
if (this.metricsManager && this.emailServer.deliverySystem) {
|
||||
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
|
||||
this.metricsManager!.trackEmailReceived(item?.from);
|
||||
logger.log('info', `Email delivery started: ${item?.from} → ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
|
||||
this.metricsManager!.trackEmailSent(item?.to);
|
||||
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
|
||||
this.metricsManager!.trackEmailFailed(item?.to, error?.message);
|
||||
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
|
||||
});
|
||||
}
|
||||
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
|
||||
if (this.metricsManager && this.emailServer) {
|
||||
this.emailServer.on('bounceProcessed', () => {
|
||||
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
|
||||
const emailLike = item?.processingResult;
|
||||
const from = emailLike?.from || emailLike?.email?.from || '';
|
||||
const recipients = Array.isArray(emailLike?.to)
|
||||
? emailLike.to
|
||||
: Array.isArray(emailLike?.email?.to)
|
||||
? emailLike.email.to
|
||||
: [];
|
||||
return {
|
||||
from,
|
||||
recipients: recipients.filter(Boolean),
|
||||
};
|
||||
};
|
||||
const updateQueueSize = () => {
|
||||
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
|
||||
};
|
||||
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailReceived(envelope.from);
|
||||
updateQueueSize();
|
||||
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
|
||||
updateQueueSize();
|
||||
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
|
||||
updateQueueSize();
|
||||
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
|
||||
updateQueueSize();
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
|
||||
updateQueueSize();
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
|
||||
this.metricsManager!.trackEmailBounced();
|
||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||
});
|
||||
updateQueueSize();
|
||||
}
|
||||
|
||||
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
||||
@@ -1559,11 +1733,7 @@ export class DcRouter {
|
||||
try {
|
||||
// Stop the unified email server which contains all components
|
||||
if (this.emailServer) {
|
||||
// Remove listeners before stopping to prevent leaks on config update cycles
|
||||
if ((this.emailServer as any).deliverySystem) {
|
||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||
}
|
||||
this.emailServer.removeAllListeners();
|
||||
this.clearEmailEventSubscriptions();
|
||||
await this.emailServer.stop();
|
||||
logger.log('info', 'Unified email server stopped');
|
||||
this.emailServer = undefined;
|
||||
@@ -1768,14 +1938,14 @@ export class DcRouter {
|
||||
// Generate and register authoritative records
|
||||
const authoritativeRecords = await this.generateAuthoritativeRecords();
|
||||
|
||||
// Generate email DNS records
|
||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||
|
||||
// Initialize DKIM for all email domains
|
||||
await this.initializeDkimForEmailDomains();
|
||||
|
||||
// Load DKIM records from JSON files (they should now exist)
|
||||
const dkimRecords = await this.loadDkimRecords();
|
||||
// Generate email DNS records
|
||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||
|
||||
// Ensure DKIM keys exist for internal-dns domains before generating records.
|
||||
await this.initializeDkimForEmailDomains();
|
||||
|
||||
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
|
||||
const dkimRecords = await this.loadDkimRecords();
|
||||
|
||||
// Combine all records: authoritative, email, DKIM, and user-defined
|
||||
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
|
||||
@@ -1886,37 +2056,20 @@ export class DcRouter {
|
||||
for (const domainConfig of internalDnsDomains) {
|
||||
const domain = domainConfig.domain;
|
||||
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
||||
const mxPriority = domainConfig.dns?.internal?.mxPriority || 10;
|
||||
|
||||
// MX record - points to the domain itself for email handling
|
||||
records.push({
|
||||
name: domain,
|
||||
type: 'MX',
|
||||
value: `${mxPriority} ${domain}`,
|
||||
ttl
|
||||
});
|
||||
|
||||
// SPF record - using sensible defaults
|
||||
const spfRecord = 'v=spf1 a mx ~all';
|
||||
records.push({
|
||||
name: domain,
|
||||
type: 'TXT',
|
||||
value: spfRecord,
|
||||
ttl
|
||||
});
|
||||
|
||||
// DMARC record - using sensible defaults
|
||||
const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring
|
||||
const dmarcEmail = `dmarc@${domain}`;
|
||||
records.push({
|
||||
name: `_dmarc.${domain}`,
|
||||
type: 'TXT',
|
||||
value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`,
|
||||
ttl
|
||||
});
|
||||
|
||||
// Note: DKIM records will be generated later when DKIM keys are available
|
||||
// They require the DKIMCreator which is part of the email server
|
||||
const requiredRecords = buildEmailDnsRecords({
|
||||
domain,
|
||||
hostname: this.options.emailConfig.hostname,
|
||||
mxPriority: domainConfig.dns?.internal?.mxPriority,
|
||||
}).filter((record) => !record.name.includes('._domainkey.'));
|
||||
|
||||
for (const record of requiredRecords) {
|
||||
records.push({
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
value: record.value,
|
||||
ttl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
|
||||
@@ -1924,54 +2077,30 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load DKIM records from JSON files
|
||||
* Reads all *.dkimrecord.json files from the DNS records directory
|
||||
* Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
|
||||
*/
|
||||
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
||||
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
||||
|
||||
try {
|
||||
// Ensure paths are imported
|
||||
const dnsDir = this.resolvedPaths.dnsRecordsDir;
|
||||
|
||||
// Check if directory exists
|
||||
if (!plugins.fs.existsSync(dnsDir)) {
|
||||
logger.log('debug', 'No DNS records directory found, skipping DKIM record loading');
|
||||
return records;
|
||||
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
|
||||
return records;
|
||||
}
|
||||
|
||||
for (const domainConfig of this.options.emailConfig.domains) {
|
||||
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read all files in the directory
|
||||
const files = plugins.fs.readdirSync(dnsDir);
|
||||
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
|
||||
|
||||
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
|
||||
|
||||
// Load each DKIM record
|
||||
for (const file of dkimFiles) {
|
||||
try {
|
||||
const filePath = plugins.path.join(dnsDir, file);
|
||||
const fileContent = plugins.fs.readFileSync(filePath, 'utf8');
|
||||
const dkimRecord = JSON.parse(fileContent);
|
||||
|
||||
// Validate record structure
|
||||
if (dkimRecord.name && dkimRecord.type === 'TXT' && dkimRecord.value) {
|
||||
records.push({
|
||||
name: dkimRecord.name,
|
||||
type: 'TXT',
|
||||
value: dkimRecord.value,
|
||||
ttl: 3600 // Standard DKIM TTL
|
||||
});
|
||||
|
||||
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
|
||||
} else {
|
||||
logger.log('warn', `Invalid DKIM record structure in ${file}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
|
||||
}
|
||||
const selector = domainConfig.dkim?.selector || 'default';
|
||||
try {
|
||||
const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
|
||||
records.push({
|
||||
name: dkimRecord.name,
|
||||
type: 'TXT',
|
||||
value: dkimRecord.value,
|
||||
ttl: domainConfig.dns?.internal?.ttl || 3600,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to generate DKIM record for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return records;
|
||||
@@ -1998,12 +2127,17 @@ export class DcRouter {
|
||||
// Ensure necessary directories exist
|
||||
paths.ensureDataDirectories(this.resolvedPaths);
|
||||
|
||||
// Generate DKIM keys for each email domain
|
||||
// Generate DKIM keys for each internal-dns email domain using the configured selector.
|
||||
for (const domainConfig of this.options.emailConfig.domains) {
|
||||
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Generate DKIM keys for all domains, regardless of DNS mode
|
||||
// This ensures keys are ready even if DNS mode changes later
|
||||
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
|
||||
await dkimCreator.handleDKIMKeysForSelector(
|
||||
domainConfig.domain,
|
||||
domainConfig.dkim?.selector || 'default',
|
||||
domainConfig.dkim?.keySize || 2048,
|
||||
);
|
||||
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||
@@ -2133,6 +2267,25 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addEmailEventSubscription(
|
||||
emitter: {
|
||||
on(eventName: string, listener: (...args: any[]) => void): void;
|
||||
off(eventName: string, listener: (...args: any[]) => void): void;
|
||||
},
|
||||
eventName: string,
|
||||
listener: (...args: any[]) => void,
|
||||
): void {
|
||||
emitter.on(eventName, listener);
|
||||
this.emailEventSubscriptions.push({ emitter, eventName, listener });
|
||||
}
|
||||
|
||||
private clearEmailEventSubscriptions(): void {
|
||||
for (const subscription of this.emailEventSubscriptions) {
|
||||
subscription.emitter.off(subscription.eventName, subscription.listener);
|
||||
}
|
||||
this.emailEventSubscriptions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the server's public IP address
|
||||
@@ -2166,14 +2319,18 @@ 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 routes so the manager can derive edge ports from remoteIngress-tagged routes
|
||||
const currentRoutes = this.constructorRoutes;
|
||||
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||
// will push the complete merged routes here.
|
||||
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
|
||||
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
||||
|
||||
// Race-condition fix: if ConfigManagers finished before us, re-apply routes
|
||||
// so the callback delivers the full merged set (including DB-stored routes)
|
||||
// to our newly-created remoteIngressManager.
|
||||
// If ConfigManagers finished before us, re-apply routes
|
||||
// so the callback delivers the full DB set to our newly-created remoteIngressManager.
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.applyRoutes();
|
||||
}
|
||||
@@ -2214,6 +2371,7 @@ export class DcRouter {
|
||||
tunnelPort: riCfg.tunnelPort ?? 8443,
|
||||
targetHost: '127.0.0.1',
|
||||
tls: tlsConfig,
|
||||
performance: riCfg.performance,
|
||||
});
|
||||
await this.tunnelManager.start();
|
||||
|
||||
@@ -2224,6 +2382,32 @@ export class DcRouter {
|
||||
/**
|
||||
* Set up VPN server for VPN-based route access control.
|
||||
*/
|
||||
private createVpnRouteAllowListResolver(): ((
|
||||
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
|
||||
routeId?: string,
|
||||
) => TIpAllowEntry[]) | undefined {
|
||||
if (!this.options.vpnConfig?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
|
||||
routeId?: string,
|
||||
) => {
|
||||
if (!this.vpnManager || !this.targetProfileManager) {
|
||||
// VPN not ready yet — deny all until re-apply after VPN starts.
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private async setupVpnServer(): Promise<void> {
|
||||
if (!this.options.vpnConfig?.enabled) {
|
||||
return;
|
||||
@@ -2260,11 +2444,10 @@ export class DcRouter {
|
||||
|
||||
if (!this.targetProfileManager) return [...ips];
|
||||
|
||||
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[];
|
||||
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
|
||||
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
|
||||
|
||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||
targetProfileIds, routes, storedRoutes,
|
||||
targetProfileIds, allRoutes,
|
||||
);
|
||||
|
||||
// Add target IPs directly
|
||||
@@ -2274,8 +2457,11 @@ export class DcRouter {
|
||||
|
||||
// Resolve DNS A records for matched domains (with caching)
|
||||
for (const domain of domains) {
|
||||
const stripped = domain.replace(/^\*\./, '');
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
||||
if (this.isWildcardVpnDomain(domain)) {
|
||||
this.logSkippedWildcardAllowedIp(domain);
|
||||
continue;
|
||||
}
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||
for (const ip of resolvedIps) {
|
||||
ips.add(`${ip}/32`);
|
||||
}
|
||||
@@ -2287,14 +2473,15 @@ export class DcRouter {
|
||||
|
||||
await this.vpnManager.start();
|
||||
|
||||
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
||||
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
|
||||
// VPN server wasn't ready yet)
|
||||
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
|
||||
// get correct profile-based ipAllowLists
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
||||
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
|
||||
private warnedWildcardVpnDomains = new Set<string>();
|
||||
|
||||
/**
|
||||
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
||||
@@ -2320,6 +2507,19 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
|
||||
private isWildcardVpnDomain(domain: string): boolean {
|
||||
return domain.includes('*');
|
||||
}
|
||||
|
||||
private logSkippedWildcardAllowedIp(domain: string): void {
|
||||
if (this.warnedWildcardVpnDomains.has(domain)) return;
|
||||
this.warnedWildcardVpnDomains.add(domain);
|
||||
logger.log(
|
||||
'warn',
|
||||
`VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
|
||||
);
|
||||
}
|
||||
|
||||
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
||||
// via the getVpnAllowList callback — no longer a separate method here.
|
||||
|
||||
@@ -2357,6 +2557,29 @@ export class DcRouter {
|
||||
|
||||
logger.log('info', 'RADIUS configuration updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update VPN configuration at runtime.
|
||||
*/
|
||||
public async updateVpnConfig(config: IDcRouterOptions['vpnConfig']): Promise<void> {
|
||||
if (this.vpnManager) {
|
||||
await this.vpnManager.stop();
|
||||
this.vpnManager = undefined;
|
||||
}
|
||||
|
||||
this.options.vpnConfig = config;
|
||||
this.vpnDomainIpCache.clear();
|
||||
this.warnedWildcardVpnDomains.clear();
|
||||
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver());
|
||||
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
await this.setupVpnServer();
|
||||
} else {
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
logger.log('info', 'VPN configuration updated');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export email server types for convenience
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
ISourceProfile,
|
||||
INetworkTarget,
|
||||
IRouteMetadata,
|
||||
IStoredRoute,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
@@ -81,7 +81,7 @@ export class ReferenceResolver {
|
||||
public async deleteProfile(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IStoredRoute>,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
@@ -131,7 +131,7 @@ export class ReferenceResolver {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
|
||||
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||
public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
|
||||
for (const profile of this.profiles.values()) {
|
||||
usage.set(profile.id, []);
|
||||
@@ -147,7 +147,7 @@ export class ReferenceResolver {
|
||||
|
||||
public getProfileUsageForId(
|
||||
profileId: string,
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
@@ -214,7 +214,7 @@ export class ReferenceResolver {
|
||||
public async deleteTarget(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IStoredRoute>,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const target = this.targets.get(id);
|
||||
if (!target) {
|
||||
@@ -263,7 +263,7 @@ export class ReferenceResolver {
|
||||
|
||||
public getTargetUsageForId(
|
||||
targetId: string,
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
@@ -334,20 +334,20 @@ export class ReferenceResolver {
|
||||
// =========================================================================
|
||||
|
||||
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
@@ -357,7 +357,7 @@ export class ReferenceResolver {
|
||||
return ids;
|
||||
}
|
||||
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.networkTargetRef === targetId) {
|
||||
@@ -547,7 +547,7 @@ export class ReferenceResolver {
|
||||
|
||||
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await StoredRouteDoc.findById(routeId);
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
@@ -562,7 +562,7 @@ export class ReferenceResolver {
|
||||
|
||||
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await StoredRouteDoc.findById(routeId);
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
|
||||
import { RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
IStoredRoute,
|
||||
IRouteOverride,
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
@@ -15,6 +14,11 @@ 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 interface IRouteMutationResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
||||
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
||||
@@ -46,66 +50,70 @@ class RouteUpdateMutex {
|
||||
}
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
private overrides = new Map<string, IRouteOverride>();
|
||||
private routes = new Map<string, IRoute>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
private routeUpdateMutex = new RouteUpdateMutex();
|
||||
|
||||
constructor(
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
||||
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
||||
) {}
|
||||
|
||||
/** Expose stored routes map for reference resolution lookups. */
|
||||
public getStoredRoutes(): Map<string, IStoredRoute> {
|
||||
return this.storedRoutes;
|
||||
/** Expose routes map for reference resolution lookups. */
|
||||
public getRoutes(): Map<string, IRoute> {
|
||||
return this.routes;
|
||||
}
|
||||
|
||||
public getRoute(id: string): IRoute | undefined {
|
||||
return this.routes.get(id);
|
||||
}
|
||||
|
||||
public setVpnClientIpsResolver(
|
||||
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
): void {
|
||||
this.getVpnClientIpsForRoute = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||
* Load persisted routes, seed serializable config/email/dns routes,
|
||||
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadStoredRoutes();
|
||||
await this.loadOverrides();
|
||||
public async initialize(
|
||||
configRoutes: IDcRouterRouteConfig[] = [],
|
||||
emailRoutes: IDcRouterRouteConfig[] = [],
|
||||
dnsRoutes: IDcRouterRouteConfig[] = [],
|
||||
): Promise<void> {
|
||||
await this.loadRoutes();
|
||||
await this.seedRoutes(configRoutes, 'config');
|
||||
await this.seedRoutes(emailRoutes, 'email');
|
||||
await this.seedRoutes(dnsRoutes, 'dns');
|
||||
this.computeWarnings();
|
||||
this.logWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Merged view
|
||||
// Route listing
|
||||
// =========================================================================
|
||||
|
||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||
const merged: IMergedRoute[] = [];
|
||||
|
||||
// Hardcoded routes
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
for (const route of this.routes.values()) {
|
||||
merged.push({
|
||||
route,
|
||||
source: 'hardcoded',
|
||||
enabled: override ? override.enabled : true,
|
||||
overridden: !!override,
|
||||
});
|
||||
}
|
||||
|
||||
// Programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
merged.push({
|
||||
route: stored.route,
|
||||
source: 'programmatic',
|
||||
enabled: stored.enabled,
|
||||
overridden: false,
|
||||
storedRouteId: stored.id,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
metadata: stored.metadata,
|
||||
route: route.route,
|
||||
id: route.id,
|
||||
enabled: route.enabled,
|
||||
origin: route.origin,
|
||||
systemKey: route.systemKey,
|
||||
createdAt: route.createdAt,
|
||||
updatedAt: route.updatedAt,
|
||||
metadata: route.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +121,7 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Programmatic route CRUD
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
@@ -127,28 +135,29 @@ export class RouteConfigManager {
|
||||
|
||||
// Ensure route has a name
|
||||
if (!route.name) {
|
||||
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||
route.name = `route-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// Resolve references if metadata has refs and resolver is available
|
||||
let resolvedMetadata = metadata;
|
||||
if (metadata && this.referenceResolver) {
|
||||
const resolved = this.referenceResolver.resolveRoute(route, metadata);
|
||||
let resolvedMetadata = this.normalizeRouteMetadata(metadata);
|
||||
if (resolvedMetadata && this.referenceResolver) {
|
||||
const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
|
||||
route = resolved.route;
|
||||
resolvedMetadata = resolved.metadata;
|
||||
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
|
||||
const stored: IStoredRoute = {
|
||||
const stored: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
origin: 'api',
|
||||
metadata: resolvedMetadata,
|
||||
};
|
||||
|
||||
this.storedRoutes.set(id, stored);
|
||||
this.routes.set(id, stored);
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return id;
|
||||
@@ -161,9 +170,21 @@ export class RouteConfigManager {
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const stored = this.storedRoutes.get(id);
|
||||
if (!stored) return false;
|
||||
): Promise<IRouteMutationResult> {
|
||||
const stored = this.routes.get(id);
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
|
||||
const isToggleOnlyPatch = patch.enabled !== undefined
|
||||
&& patch.route === undefined
|
||||
&& patch.metadata === undefined;
|
||||
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'System routes are managed by the system and can only be toggled',
|
||||
};
|
||||
}
|
||||
|
||||
if (patch.route) {
|
||||
const mergedAction = patch.route.action
|
||||
@@ -177,178 +198,321 @@ export class RouteConfigManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
stored.route = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
|
||||
const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
|
||||
|
||||
// Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null)
|
||||
for (const [key, val] of Object.entries(patch.route)) {
|
||||
if (val === null && key !== 'action' && key !== 'match') {
|
||||
delete (mergedRoute as any)[key];
|
||||
}
|
||||
}
|
||||
|
||||
stored.route = mergedRoute;
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
stored.enabled = patch.enabled;
|
||||
}
|
||||
if (patch.metadata !== undefined) {
|
||||
stored.metadata = { ...stored.metadata, ...patch.metadata };
|
||||
stored.metadata = this.normalizeRouteMetadata({
|
||||
...stored.metadata,
|
||||
...patch.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-resolve if metadata refs exist and resolver is available
|
||||
if (stored.metadata && this.referenceResolver) {
|
||||
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||
stored.route = resolved.route;
|
||||
stored.metadata = resolved.metadata;
|
||||
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
|
||||
stored.updatedAt = Date.now();
|
||||
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async deleteRoute(id: string): Promise<boolean> {
|
||||
if (!this.storedRoutes.has(id)) return false;
|
||||
this.storedRoutes.delete(id);
|
||||
const doc = await StoredRouteDoc.findById(id);
|
||||
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
|
||||
const stored = this.routes.get(id);
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
if (stored.origin !== 'api') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'System routes are managed by the system and cannot be deleted',
|
||||
};
|
||||
}
|
||||
|
||||
this.routes.delete(id);
|
||||
const doc = await RouteDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
|
||||
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
|
||||
return this.updateRoute(id, { enabled });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hardcoded route overrides
|
||||
// =========================================================================
|
||||
|
||||
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
||||
const override: IRouteOverride = {
|
||||
routeName,
|
||||
enabled,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy,
|
||||
};
|
||||
this.overrides.set(routeName, override);
|
||||
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||
if (existingDoc) {
|
||||
existingDoc.enabled = override.enabled;
|
||||
existingDoc.updatedAt = override.updatedAt;
|
||||
existingDoc.updatedBy = override.updatedBy;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new RouteOverrideDoc();
|
||||
doc.routeName = override.routeName;
|
||||
doc.enabled = override.enabled;
|
||||
doc.updatedAt = override.updatedAt;
|
||||
doc.updatedBy = override.updatedBy;
|
||||
await doc.save();
|
||||
public findApiRouteByExternalKey(externalKey: string): IRoute | undefined {
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.origin === 'api' && route.metadata?.externalKey === externalKey) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async removeOverride(routeName: string): Promise<boolean> {
|
||||
if (!this.overrides.has(routeName)) return false;
|
||||
this.overrides.delete(routeName);
|
||||
const doc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||
if (doc) await doc.delete();
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
// =========================================================================
|
||||
// Private: seed routes from constructor config
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
|
||||
* Deletes stale DB routes whose origin matches but name is not in the seed set.
|
||||
*/
|
||||
private async seedRoutes(
|
||||
seedRoutes: IDcRouterRouteConfig[],
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
): Promise<void> {
|
||||
const seedSystemKeys = new Set<string>();
|
||||
const seedNames = new Set<string>();
|
||||
let seeded = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const route of seedRoutes) {
|
||||
const name = route.name || '';
|
||||
if (name) {
|
||||
seedNames.add(name);
|
||||
}
|
||||
const systemKey = this.buildSystemRouteKey(origin, route);
|
||||
if (systemKey) {
|
||||
seedSystemKeys.add(systemKey);
|
||||
}
|
||||
|
||||
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
|
||||
|
||||
if (existingId) {
|
||||
// Update route config but preserve enabled state
|
||||
const existing = this.routes.get(existingId)!;
|
||||
existing.route = route;
|
||||
existing.systemKey = systemKey;
|
||||
existing.updatedAt = Date.now();
|
||||
await this.persistRoute(existing);
|
||||
updated++;
|
||||
} else {
|
||||
// Insert new seed route
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
const newRoute: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'system',
|
||||
origin,
|
||||
systemKey,
|
||||
};
|
||||
this.routes.set(id, newRoute);
|
||||
await this.persistRoute(newRoute);
|
||||
seeded++;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stale routes: same origin but name not in current seed set
|
||||
const staleIds: string[] = [];
|
||||
for (const [id, r] of this.routes) {
|
||||
if (r.origin !== origin) continue;
|
||||
|
||||
const routeName = r.route.name || '';
|
||||
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
|
||||
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
|
||||
if (!matchesSeedSystemKey && !matchesSeedName) {
|
||||
staleIds.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of staleIds) {
|
||||
this.routes.delete(id);
|
||||
const doc = await RouteDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
}
|
||||
|
||||
if (seeded > 0 || updated > 0 || staleIds.length > 0) {
|
||||
logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadStoredRoutes(): Promise<void> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.storedRoutes.set(doc.id, {
|
||||
id: doc.id,
|
||||
route: doc.route,
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
metadata: doc.metadata,
|
||||
});
|
||||
private buildSystemRouteKey(
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
route: IDcRouterRouteConfig,
|
||||
): string | undefined {
|
||||
const name = route.name?.trim();
|
||||
if (!name) return undefined;
|
||||
return `${origin}:${name}`;
|
||||
}
|
||||
|
||||
private findExistingSeedRouteId(
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
route: IDcRouterRouteConfig,
|
||||
systemKey?: string,
|
||||
): string | undefined {
|
||||
const routeName = route.name || '';
|
||||
|
||||
for (const [id, storedRoute] of this.routes) {
|
||||
if (storedRoute.origin !== origin) continue;
|
||||
|
||||
if (systemKey && storedRoute.systemKey === systemKey) {
|
||||
return id;
|
||||
}
|
||||
|
||||
if (storedRoute.route.name === routeName) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if (this.storedRoutes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async loadRoutes(): Promise<void> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
|
||||
for (const doc of docs) {
|
||||
if (!doc.id) continue;
|
||||
|
||||
const storedRoute: IRoute = {
|
||||
id: doc.id,
|
||||
route: doc.route,
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
origin: doc.origin || 'api',
|
||||
systemKey: doc.systemKey,
|
||||
metadata: this.normalizeRouteMetadata(doc.metadata),
|
||||
};
|
||||
|
||||
this.routes.set(doc.id, storedRoute);
|
||||
}
|
||||
if (this.routes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOverrides(): Promise<void> {
|
||||
const docs = await RouteOverrideDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.routeName) {
|
||||
this.overrides.set(doc.routeName, {
|
||||
routeName: doc.routeName,
|
||||
enabled: doc.enabled,
|
||||
updatedAt: doc.updatedAt,
|
||||
updatedBy: doc.updatedBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.overrides.size > 0) {
|
||||
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||
const existingDoc = await StoredRouteDoc.findById(stored.id);
|
||||
private async persistRoute(stored: IRoute): Promise<void> {
|
||||
const existingDoc = await RouteDoc.findById(stored.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.route = stored.route;
|
||||
existingDoc.enabled = stored.enabled;
|
||||
existingDoc.updatedAt = stored.updatedAt;
|
||||
existingDoc.createdBy = stored.createdBy;
|
||||
existingDoc.origin = stored.origin;
|
||||
existingDoc.systemKey = stored.systemKey;
|
||||
existingDoc.metadata = stored.metadata;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new StoredRouteDoc();
|
||||
const doc = new RouteDoc();
|
||||
doc.id = stored.id;
|
||||
doc.route = stored.route;
|
||||
doc.enabled = stored.enabled;
|
||||
doc.createdAt = stored.createdAt;
|
||||
doc.updatedAt = stored.updatedAt;
|
||||
doc.createdBy = stored.createdBy;
|
||||
doc.origin = stored.origin;
|
||||
doc.systemKey = stored.systemKey;
|
||||
doc.metadata = stored.metadata;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizeString = (value?: string): string | undefined => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalized: IRouteMetadata = {
|
||||
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
|
||||
networkTargetRef: normalizeString(metadata.networkTargetRef),
|
||||
sourceProfileName: normalizeString(metadata.sourceProfileName),
|
||||
networkTargetName: normalizeString(metadata.networkTargetName),
|
||||
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
||||
? metadata.lastResolvedAt
|
||||
: undefined,
|
||||
ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
|
||||
? metadata.ownerType
|
||||
: undefined,
|
||||
gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
|
||||
? metadata.gatewayClientType
|
||||
: metadata.workHosterType,
|
||||
gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
|
||||
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
|
||||
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
|
||||
? metadata.workHosterType
|
||||
: metadata.gatewayClientType,
|
||||
workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
|
||||
workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
|
||||
externalKey: normalizeString(metadata.externalKey),
|
||||
};
|
||||
|
||||
if (!normalized.sourceProfileRef) {
|
||||
normalized.sourceProfileName = undefined;
|
||||
}
|
||||
if (!normalized.networkTargetRef) {
|
||||
normalized.networkTargetName = undefined;
|
||||
}
|
||||
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
||||
normalized.lastResolvedAt = undefined;
|
||||
}
|
||||
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
||||
normalized.gatewayClientType = undefined;
|
||||
normalized.gatewayClientId = undefined;
|
||||
normalized.gatewayClientAppId = undefined;
|
||||
normalized.workHosterType = undefined;
|
||||
normalized.workHosterId = undefined;
|
||||
normalized.workAppId = undefined;
|
||||
normalized.externalKey = undefined;
|
||||
} else {
|
||||
normalized.ownerType = 'gatewayClient';
|
||||
normalized.workHosterType = normalized.gatewayClientType;
|
||||
normalized.workHosterId = normalized.gatewayClientId;
|
||||
normalized.workAppId = normalized.gatewayClientAppId;
|
||||
}
|
||||
|
||||
if (Object.values(normalized).every((value) => value === undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: warnings
|
||||
// =========================================================================
|
||||
|
||||
private computeWarnings(): void {
|
||||
this.warnings = [];
|
||||
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||
|
||||
// Check overrides
|
||||
for (const [routeName, override] of this.overrides) {
|
||||
if (!hardcodedNames.has(routeName)) {
|
||||
for (const route of this.routes.values()) {
|
||||
if (!route.enabled) {
|
||||
const name = route.route.name || route.id;
|
||||
this.warnings.push({
|
||||
type: 'orphaned-override',
|
||||
routeName,
|
||||
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
||||
});
|
||||
} else if (!override.enabled) {
|
||||
this.warnings.push({
|
||||
type: 'disabled-hardcoded',
|
||||
routeName,
|
||||
message: `Route '${routeName}' is disabled via API override`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check disabled programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (!stored.enabled) {
|
||||
const name = stored.route.name || stored.id;
|
||||
this.warnings.push({
|
||||
type: 'disabled-programmatic',
|
||||
type: 'disabled-route',
|
||||
routeName: name,
|
||||
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
||||
message: `Route '${name}' (id: ${route.id}) is disabled`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -372,12 +536,12 @@ export class RouteConfigManager {
|
||||
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||
|
||||
for (const routeId of routeIds) {
|
||||
const stored = this.storedRoutes.get(routeId);
|
||||
const stored = this.routes.get(routeId);
|
||||
if (!stored?.metadata) continue;
|
||||
|
||||
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||
stored.route = resolved.route;
|
||||
stored.metadata = resolved.metadata;
|
||||
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
stored.updatedAt = Date.now();
|
||||
await this.persistRoute(stored);
|
||||
}
|
||||
@@ -387,7 +551,7 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: apply merged routes to SmartProxy
|
||||
// Apply routes to SmartProxy
|
||||
// =========================================================================
|
||||
|
||||
public async applyRoutes(): Promise<void> {
|
||||
@@ -397,54 +561,66 @@ export class RouteConfigManager {
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
|
||||
// Helper: inject VPN security into a vpnOnly route
|
||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||
if (!vpnCallback) return route;
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||
const existingEntries = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
if (override && !override.enabled) {
|
||||
continue; // Skip disabled hardcoded route
|
||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(this.prepareStoredRouteForApply(route));
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config?.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route, stored.id));
|
||||
}
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
await this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
||||
});
|
||||
}
|
||||
|
||||
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
|
||||
}
|
||||
|
||||
private prepareRouteForApply(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
let preparedRoute = route;
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
|
||||
if (http3Config?.enabled !== false) {
|
||||
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
|
||||
}
|
||||
|
||||
return this.injectVpnSecurity(preparedRoute, routeId);
|
||||
}
|
||||
|
||||
private injectVpnSecurity(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
if (!vpnCallback) return route;
|
||||
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
|
||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||
const existingEntries = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { logger } from '../logger.js';
|
||||
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
|
||||
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
/**
|
||||
* Manages TargetProfiles (target-side: what can be accessed).
|
||||
@@ -13,6 +13,10 @@ import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'
|
||||
export class TargetProfileManager {
|
||||
private profiles = new Map<string, ITargetProfile>();
|
||||
|
||||
constructor(
|
||||
private getAllRoutes?: () => Map<string, IRoute>,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
@@ -43,13 +47,14 @@ export class TargetProfileManager {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||
const profile: ITargetProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
domains: data.domains,
|
||||
targets: data.targets,
|
||||
routeRefs: data.routeRefs,
|
||||
routeRefs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
@@ -70,11 +75,19 @@ export class TargetProfileManager {
|
||||
throw new Error(`Target profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined && patch.name !== profile.name) {
|
||||
for (const existing of this.profiles.values()) {
|
||||
if (existing.id !== id && existing.name === patch.name) {
|
||||
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
if (patch.description !== undefined) profile.description = patch.description;
|
||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
|
||||
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
@@ -127,6 +140,29 @@ export class TargetProfileManager {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize stored route references to route IDs when they can be resolved
|
||||
* uniquely against the current route registry.
|
||||
*/
|
||||
public async normalizeAllRouteRefs(): Promise<void> {
|
||||
const allRoutes = this.getAllRoutes?.();
|
||||
if (!allRoutes?.size) return;
|
||||
|
||||
for (const profile of this.profiles.values()) {
|
||||
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
||||
profile.routeRefs,
|
||||
allRoutes,
|
||||
'bestEffort',
|
||||
);
|
||||
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
||||
|
||||
profile.routeRefs = normalizedRouteRefs;
|
||||
profile.updatedAt = Date.now();
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
public listProfiles(): ITargetProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
@@ -178,9 +214,11 @@ export class TargetProfileManager {
|
||||
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 || [];
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
@@ -194,7 +232,13 @@ export class TargetProfileManager {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) continue;
|
||||
|
||||
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
const matchResult = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
if (matchResult === 'full') {
|
||||
fullAccess = true;
|
||||
break; // No need to check more profiles
|
||||
@@ -220,11 +264,11 @@ export class TargetProfileManager {
|
||||
*/
|
||||
public getClientAccessSpec(
|
||||
targetProfileIds: string[],
|
||||
allRoutes: IDcRouterRouteConfig[],
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
allRoutes: Map<string, IRoute>,
|
||||
): { domains: string[]; targetIps: string[] } {
|
||||
const domains = new Set<string>();
|
||||
const targetIps = new Set<string>();
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
// Collect all access specifiers from assigned profiles
|
||||
for (const profileId of targetProfileIds) {
|
||||
@@ -245,23 +289,16 @@ export class TargetProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Route references: scan constructor routes
|
||||
for (const route of allRoutes) {
|
||||
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
|
||||
const routeDomains = (route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route references: scan stored routes
|
||||
for (const [storedId, stored] of storedRoutes) {
|
||||
if (!stored.enabled) continue;
|
||||
if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) {
|
||||
const routeDomains = (stored.route.match as any)?.domains;
|
||||
// Route references: scan all routes
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
if (!route.enabled) continue;
|
||||
if (this.routeMatchesProfile(
|
||||
route.route as IDcRouterRouteConfig,
|
||||
routeId,
|
||||
profile,
|
||||
routeNameIndex,
|
||||
)) {
|
||||
const routeDomains = (route.route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
@@ -288,9 +325,16 @@ export class TargetProfileManager {
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): boolean {
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
const result = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
return result !== 'none';
|
||||
}
|
||||
|
||||
@@ -307,11 +351,17 @@ export class TargetProfileManager {
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeDomains: string[],
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||
// 1. Route reference match → full access
|
||||
if (profile.routeRefs?.length) {
|
||||
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
||||
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
|
||||
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
||||
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
||||
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Domain match
|
||||
@@ -375,6 +425,66 @@ export class TargetProfileManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||
}
|
||||
|
||||
private normalizeRouteRefsAgainstRoutes(
|
||||
routeRefs: string[] | undefined,
|
||||
allRoutes: Map<string, IRoute>,
|
||||
mode: 'strict' | 'bestEffort',
|
||||
): string[] | undefined {
|
||||
if (!routeRefs?.length) return undefined;
|
||||
if (!allRoutes.size) return [...new Set(routeRefs)];
|
||||
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
const normalizedRefs = new Set<string>();
|
||||
|
||||
for (const routeRef of routeRefs) {
|
||||
if (allRoutes.has(routeRef)) {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
||||
if (matchingRouteIds.length === 1) {
|
||||
normalizedRefs.add(matchingRouteIds[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode === 'bestEffort') {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchingRouteIds.length > 1) {
|
||||
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
||||
}
|
||||
throw new Error(`Route reference '${routeRef}' not found`);
|
||||
}
|
||||
|
||||
return [...normalizedRefs];
|
||||
}
|
||||
|
||||
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
||||
const routeNameIndex = new Map<string, string[]>();
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
const routeName = route.route.name;
|
||||
if (!routeName) continue;
|
||||
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
||||
matchingRouteIds.push(routeId);
|
||||
routeNameIndex.set(routeName, matchingRouteIds);
|
||||
}
|
||||
return routeNameIndex;
|
||||
}
|
||||
|
||||
private sameStringArray(left?: string[], right?: string[]): boolean {
|
||||
if (!left?.length && !right?.length) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
@@ -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,56 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
IEmailDomainDkim,
|
||||
IEmailDomainRateLimits,
|
||||
IEmailDomainDnsStatus,
|
||||
} from '../../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomainDoc, EmailDomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public linkedDomainId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public subdomain?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dkim!: IEmailDomainDkim;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public rateLimits?: IEmailDomainRateLimits;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dnsStatus!: IEmailDomainDnsStatus;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<EmailDomainDoc[]> {
|
||||
return await EmailDomainDoc.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({});
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public routeName!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
|
||||
return await RouteOverrideDoc.getInstance({ routeName });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<RouteOverrideDoc[]> {
|
||||
return await RouteOverrideDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public route!: IDcRouterRouteConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public origin!: 'config' | 'email' | 'dns' | 'api';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public systemKey?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata?: IRouteMetadata;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<RouteDoc[]> {
|
||||
return await RouteDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ 'route.name': name });
|
||||
}
|
||||
|
||||
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
|
||||
return await RouteDoc.getInstances({ origin });
|
||||
}
|
||||
|
||||
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ systemKey });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityBlockRule, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityBlockRuleDoc extends plugins.smartdata.SmartDataDbDoc<SecurityBlockRuleDoc, SecurityBlockRuleDoc> implements ISecurityBlockRule {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TSecurityBlockRuleType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public matchMode?: TSecurityBlockRuleMatchMode;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public reason?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy: string = 'system';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SecurityBlockRuleDoc | null> {
|
||||
return await SecurityBlockRuleDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityPolicyAuditEvent } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityPolicyAuditDoc extends plugins.smartdata.SmartDataDbDoc<SecurityPolicyAuditDoc, SecurityPolicyAuditDoc> implements ISecurityPolicyAuditEvent {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public action!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public actor!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public details!: Record<string, unknown>;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findRecent(limit = 100): Promise<SecurityPolicyAuditDoc[]> {
|
||||
const docs = await SecurityPolicyAuditDoc.getInstances({});
|
||||
return docs.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public route!: IDcRouterRouteConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata?: IRouteMetadata;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<StoredRouteDoc | null> {
|
||||
return await StoredRouteDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<StoredRouteDoc[]> {
|
||||
return await StoredRouteDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
// 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.stored-route.doc.js';
|
||||
export * from './classes.route-override.doc.js';
|
||||
export * from './classes.route.doc.js';
|
||||
export * from './classes.api-token.doc.js';
|
||||
export * from './classes.source-profile.doc.js';
|
||||
export * from './classes.target-profile.doc.js';
|
||||
@@ -33,3 +35,6 @@ export * from './classes.dns-record.doc.js';
|
||||
|
||||
// ACME configuration (singleton)
|
||||
export * from './classes.acme-config.doc.js';
|
||||
|
||||
// Email domain management
|
||||
export * from './classes.email-domain.doc.js';
|
||||
|
||||
+221
-37
@@ -97,8 +97,8 @@ export class DnsManager {
|
||||
if (hasLegacyConfig) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
|
||||
'Manage DNS via the Domains UI instead.',
|
||||
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
|
||||
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -296,70 +296,99 @@ export class DnsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
|
||||
* to decide whether to wire SmartAcme with a DNS-01 handler.
|
||||
* Find the DomainDoc that covers a given FQDN, regardless of source
|
||||
* (dcrouter-hosted or provider-managed). Uses longest-suffix match.
|
||||
*/
|
||||
public async hasAcmeCapableProvider(): Promise<boolean> {
|
||||
const providers = await DnsProviderDoc.findAll();
|
||||
return providers.length > 0;
|
||||
public async findDomainForFqdn(fqdn: string): Promise<DomainDoc | null> {
|
||||
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
|
||||
const allDomains = await DomainDoc.findAll();
|
||||
// Sort by name length descending for longest-match-wins
|
||||
allDomains.sort((a, b) => b.name.length - a.name.length);
|
||||
for (const domain of allDomains) {
|
||||
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an IConvenientDnsProvider that dispatches each ACME challenge to
|
||||
* the right provider client (whichever provider type owns the parent zone),
|
||||
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
|
||||
* interface, so any registered provider implementation works.
|
||||
* Returned object plugs directly into smartacme's Dns01Handler.
|
||||
* 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).
|
||||
*/
|
||||
public async deleteRecordsByNameAndType(
|
||||
domainId: string,
|
||||
name: string,
|
||||
type: TDnsRecordType,
|
||||
): Promise<void> {
|
||||
const records = await DnsRecordDoc.findByDomainId(domainId);
|
||||
for (const rec of records) {
|
||||
if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
|
||||
await this.deleteRecord(rec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any domain is under management (dcrouter-hosted or provider-managed).
|
||||
* Used by setupSmartProxy() to decide whether to wire SmartAcme with a DNS-01 handler.
|
||||
*/
|
||||
public async hasAnyManagedDomain(): Promise<boolean> {
|
||||
const domains = await DomainDoc.findAll();
|
||||
return domains.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an IConvenientDnsProvider that routes ACME DNS-01 challenges through
|
||||
* the DnsManager abstraction. Challenges are dispatched via createRecord() /
|
||||
* deleteRecord(), which transparently handle both dcrouter-hosted zones
|
||||
* (embedded DnsServer) and provider-managed zones (e.g. Cloudflare API).
|
||||
*
|
||||
* Only domains under management (with a DomainDoc in DB) are supported —
|
||||
* this acts as the management gate for certificate issuance.
|
||||
*/
|
||||
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
|
||||
const self = this;
|
||||
const adapter = {
|
||||
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
||||
if (!client) {
|
||||
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
|
||||
if (!domainDoc) {
|
||||
throw new Error(
|
||||
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
|
||||
'Add one in the Domains > Providers UI before issuing certificates.',
|
||||
`DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` +
|
||||
'Add the domain in Domains before issuing certificates.',
|
||||
);
|
||||
}
|
||||
// Clean any leftover challenge records first to avoid duplicates.
|
||||
// Clean leftover challenge records first to avoid duplicates.
|
||||
try {
|
||||
const existing = await client.listRecords(dnsChallenge.hostName);
|
||||
for (const r of existing) {
|
||||
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
||||
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
|
||||
}
|
||||
}
|
||||
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||
}
|
||||
await client.createRecord(dnsChallenge.hostName, {
|
||||
// Create the challenge TXT record via the unified path
|
||||
await self.createRecord({
|
||||
domainId: domainDoc.id,
|
||||
name: dnsChallenge.hostName,
|
||||
type: 'TXT',
|
||||
value: dnsChallenge.challenge,
|
||||
ttl: 120,
|
||||
createdBy: 'acme-dns01',
|
||||
});
|
||||
},
|
||||
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
||||
if (!client) {
|
||||
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
|
||||
if (!domainDoc) {
|
||||
// The domain may have been removed; nothing to clean up.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const existing = await client.listRecords(dnsChallenge.hostName);
|
||||
for (const r of existing) {
|
||||
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
||||
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
|
||||
}
|
||||
}
|
||||
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
async isDomainSupported(domain: string): Promise<boolean> {
|
||||
const client = await self.getProviderClientForDomain(domain);
|
||||
return !!client;
|
||||
const domainDoc = await self.findDomainForFqdn(domain);
|
||||
return !!domainDoc;
|
||||
},
|
||||
};
|
||||
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
|
||||
@@ -642,6 +671,151 @@ export class DnsManager {
|
||||
return await DnsRecordDoc.findById(id);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Domain migration
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Migrate a domain between dcrouter-hosted and provider-managed.
|
||||
* Transfers all records to the target and updates domain metadata.
|
||||
*/
|
||||
public async migrateDomain(args: {
|
||||
id: string;
|
||||
targetSource: 'dcrouter' | 'provider';
|
||||
targetProviderId?: string;
|
||||
deleteExistingProviderRecords?: boolean;
|
||||
}): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||
const domain = await DomainDoc.findById(args.id);
|
||||
if (!domain) return { success: false, message: 'Domain not found' };
|
||||
|
||||
if (domain.source === args.targetSource && domain.providerId === args.targetProviderId) {
|
||||
return { success: false, message: 'Domain is already in the target configuration' };
|
||||
}
|
||||
|
||||
const records = await DnsRecordDoc.findByDomainId(domain.id);
|
||||
|
||||
if (args.targetSource === 'provider') {
|
||||
return this.migrateToDnsProvider(domain, records, args.targetProviderId!, args.deleteExistingProviderRecords ?? false);
|
||||
} else {
|
||||
return this.migrateToDcrouter(domain, records);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate domain from dcrouter-hosted (or another provider) to an external DNS provider.
|
||||
*/
|
||||
private async migrateToDnsProvider(
|
||||
domain: DomainDoc,
|
||||
records: DnsRecordDoc[],
|
||||
targetProviderId: string,
|
||||
deleteExistingProviderRecords: boolean,
|
||||
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||
// Validate the target provider exists
|
||||
const client = await this.getProviderClientById(targetProviderId);
|
||||
if (!client) {
|
||||
return { success: false, message: 'Target DNS provider not found' };
|
||||
}
|
||||
|
||||
// Find the zone at the provider
|
||||
const providerDomains = await client.listDomains();
|
||||
const zone = providerDomains.find(
|
||||
(z) => z.name.toLowerCase() === domain.name.toLowerCase(),
|
||||
);
|
||||
if (!zone) {
|
||||
return { success: false, message: `Zone "${domain.name}" not found at the target provider` };
|
||||
}
|
||||
|
||||
// Optionally delete existing records at the provider
|
||||
if (deleteExistingProviderRecords) {
|
||||
try {
|
||||
const existingProviderRecords = await client.listRecords(domain.name);
|
||||
for (const pr of existingProviderRecords) {
|
||||
await client.deleteRecord(domain.name, pr.providerRecordId).catch(() => {});
|
||||
}
|
||||
logger.log('info', `Deleted ${existingProviderRecords.length} existing records at provider for ${domain.name}`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to clean existing provider records for ${domain.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Push each local record to the provider
|
||||
let migrated = 0;
|
||||
for (const rec of records) {
|
||||
try {
|
||||
const providerRecord = await client.createRecord(domain.name, {
|
||||
name: rec.name,
|
||||
type: rec.type as any,
|
||||
value: rec.value,
|
||||
ttl: rec.ttl,
|
||||
});
|
||||
// Unregister from embedded DnsServer if it was dcrouter-hosted
|
||||
if (domain.source === 'dcrouter') {
|
||||
this.unregisterRecordFromDnsServer(rec);
|
||||
}
|
||||
// Update the record doc to synced
|
||||
rec.source = 'synced' as TDnsRecordSource;
|
||||
rec.providerRecordId = providerRecord.providerRecordId;
|
||||
await rec.save();
|
||||
migrated++;
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to migrate record ${rec.name} ${rec.type} to provider: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain metadata
|
||||
domain.source = 'provider';
|
||||
domain.authoritative = false;
|
||||
domain.providerId = targetProviderId;
|
||||
domain.externalZoneId = zone.externalId;
|
||||
domain.nameservers = zone.nameservers;
|
||||
domain.lastSyncedAt = Date.now();
|
||||
domain.updatedAt = Date.now();
|
||||
await domain.save();
|
||||
|
||||
logger.log('info', `Domain ${domain.name} migrated to provider (${migrated} records)`);
|
||||
return { success: true, recordsMigrated: migrated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate domain from provider-managed to dcrouter-hosted (authoritative).
|
||||
*/
|
||||
private async migrateToDcrouter(
|
||||
domain: DomainDoc,
|
||||
records: DnsRecordDoc[],
|
||||
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||
// Register each record with the embedded DnsServer
|
||||
let migrated = 0;
|
||||
for (const rec of records) {
|
||||
try {
|
||||
this.registerRecordWithDnsServer(rec);
|
||||
// Update the record doc to local
|
||||
rec.source = 'local' as TDnsRecordSource;
|
||||
rec.providerRecordId = undefined;
|
||||
await rec.save();
|
||||
migrated++;
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to register record ${rec.name} ${rec.type} with DnsServer: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain metadata
|
||||
domain.source = 'dcrouter';
|
||||
domain.authoritative = true;
|
||||
domain.providerId = undefined;
|
||||
domain.externalZoneId = undefined;
|
||||
domain.nameservers = undefined;
|
||||
domain.lastSyncedAt = undefined;
|
||||
domain.updatedAt = Date.now();
|
||||
await domain.save();
|
||||
|
||||
logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`);
|
||||
return { success: true, recordsMigrated: migrated };
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Record CRUD
|
||||
// ==========================================================================
|
||||
|
||||
public async createRecord(args: {
|
||||
domainId: string;
|
||||
name: string;
|
||||
@@ -759,14 +933,24 @@ export class DnsManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// For local records: smartdns has no unregister API in the pinned version,
|
||||
// so the record stays served until the next restart. The DB delete still
|
||||
// takes effect — on restart, the record will not be re-registered.
|
||||
// For dcrouter-hosted records: unregister the handler from the embedded DnsServer
|
||||
// so the record stops being served immediately (not just after restart).
|
||||
if (domain.source === 'dcrouter' && this.dnsServer) {
|
||||
this.unregisterRecordFromDnsServer(doc);
|
||||
}
|
||||
|
||||
await doc.delete();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a record's handler from the embedded DnsServer.
|
||||
*/
|
||||
public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void {
|
||||
if (!this.dnsServer) return;
|
||||
this.dnsServer.unregisterHandler(rec.name, [rec.type]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Internal helpers
|
||||
// ==========================================================================
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
import { logger } from '../logger.js';
|
||||
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
||||
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
||||
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
||||
import type { DnsManager } from '../dns/manager.dns.js';
|
||||
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
||||
import { buildEmailDnsRecords } from './email-dns-records.js';
|
||||
|
||||
/**
|
||||
* EmailDomainManager — orchestrates email domain setup.
|
||||
*
|
||||
* Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager
|
||||
* (record creation for dcrouter-hosted and provider-managed zones) to provide
|
||||
* a single entry point for setting up an email domain from A to Z.
|
||||
*/
|
||||
export class EmailDomainManager {
|
||||
private dcRouter: any; // DcRouter — avoids circular import
|
||||
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||
|
||||
constructor(dcRouterRef: any) {
|
||||
this.dcRouter = dcRouterRef;
|
||||
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
private get dnsManager(): DnsManager | undefined {
|
||||
return this.dcRouter.dnsManager;
|
||||
}
|
||||
|
||||
private get dkimCreator(): any | undefined {
|
||||
return this.dcRouter.emailServer?.dkimCreator;
|
||||
}
|
||||
|
||||
private get emailHostname(): string {
|
||||
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public async getAll(): Promise<IEmailDomain[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
return docs.map((d) => this.docToInterface(d));
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<IEmailDomain | null> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
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;
|
||||
dkimSelector?: string;
|
||||
dkimKeySize?: number;
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
}): Promise<IEmailDomain> {
|
||||
// Resolve the linked DNS domain
|
||||
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
|
||||
if (!domainDoc) {
|
||||
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
|
||||
}
|
||||
const baseDomain = domainDoc.name;
|
||||
const subdomain = opts.subdomain?.trim() || undefined;
|
||||
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||
|
||||
// Check for duplicates
|
||||
if (this.isDomainAlreadyConfigured(domainName)) {
|
||||
throw new Error(`Email domain already configured for ${domainName}`);
|
||||
}
|
||||
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||
if (existing) {
|
||||
throw new Error(`Email domain already exists for ${domainName}`);
|
||||
}
|
||||
|
||||
const selector = opts.dkimSelector || 'default';
|
||||
const keySize = opts.dkimKeySize || 2048;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Generate DKIM keys
|
||||
let publicKey: string | undefined;
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
||||
// Extract public key from the DNS record value
|
||||
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
||||
publicKey = match ? match[1] : undefined;
|
||||
logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the document
|
||||
const doc = new EmailDomainDoc();
|
||||
doc.id = plugins.smartunique.shortId();
|
||||
doc.domain = domainName.toLowerCase();
|
||||
doc.linkedDomainId = opts.linkedDomainId;
|
||||
doc.subdomain = subdomain;
|
||||
doc.dkim = {
|
||||
selector,
|
||||
keySize,
|
||||
publicKey,
|
||||
rotateKeys: opts.rotateKeys ?? false,
|
||||
rotationIntervalDays: opts.rotationIntervalDays ?? 90,
|
||||
};
|
||||
doc.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
await doc.save();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
|
||||
logger.log('info', `Email domain created: ${domainName}`);
|
||||
return this.docToInterface(doc);
|
||||
}
|
||||
|
||||
public async updateEmailDomain(
|
||||
id: string,
|
||||
changes: {
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
rateLimits?: IEmailDomain['rateLimits'];
|
||||
},
|
||||
): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
|
||||
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
|
||||
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
}
|
||||
|
||||
public async deleteEmailDomain(id: string): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
await doc.delete();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS record computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the 4 required DNS records for an email domain.
|
||||
*/
|
||||
public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const hostname = this.emailHostname;
|
||||
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
||||
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
|
||||
dkimValue = dnsRecord.value;
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return buildEmailDnsRecords({
|
||||
domain,
|
||||
hostname,
|
||||
selector,
|
||||
dkimValue,
|
||||
statuses: doc.dnsStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS provisioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Auto-create missing DNS records via the linked domain's DNS path.
|
||||
*/
|
||||
public async provisionDnsRecords(id: string): Promise<number> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
if (!this.dnsManager) throw new Error('DnsManager not available');
|
||||
|
||||
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||
const domainId = doc.linkedDomainId;
|
||||
|
||||
// Get existing DNS records for the linked domain
|
||||
const existingRecords = await DnsRecordDoc.findByDomainId(domainId);
|
||||
let provisioned = 0;
|
||||
|
||||
for (const required of requiredRecords) {
|
||||
// Check if a matching record already exists
|
||||
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
||||
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.dnsManager.createRecord({
|
||||
domainId,
|
||||
name: required.name,
|
||||
type: required.type as any,
|
||||
value: required.value,
|
||||
ttl: 3600,
|
||||
createdBy: 'email-domain-manager',
|
||||
});
|
||||
provisioned++;
|
||||
logger.log('info', `Provisioned ${required.type} record for ${required.name}`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate after provisioning
|
||||
await this.validateDns(id);
|
||||
|
||||
return provisioned;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate DNS records via live lookups.
|
||||
*/
|
||||
public async validateDns(id: string): Promise<IEmailDnsRecord[]> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const resolver = new plugins.dns.promises.Resolver();
|
||||
|
||||
// MX check
|
||||
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||
|
||||
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
|
||||
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
|
||||
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
||||
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
||||
|
||||
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
|
||||
|
||||
// SPF check
|
||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
||||
|
||||
// DKIM check
|
||||
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
||||
|
||||
// DMARC check
|
||||
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
|
||||
|
||||
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
|
||||
return this.getRequiredDnsRecords(id);
|
||||
}
|
||||
|
||||
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
|
||||
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
return record.value.trim() === required.value.trim();
|
||||
}
|
||||
|
||||
private async checkMx(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
domain: string,
|
||||
expectedValue?: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveMx(domain);
|
||||
if (!records || records.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
if (!expectedValue) {
|
||||
return 'valid';
|
||||
}
|
||||
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
|
||||
return found ? 'valid' : 'invalid';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
private async checkTxtRecord(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
name: string,
|
||||
expectedValue?: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveTxt(name);
|
||||
const flat = records.map((r) => r.join(''));
|
||||
if (flat.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
if (!expectedValue) {
|
||||
return 'valid';
|
||||
}
|
||||
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
||||
return found ? 'valid' : 'invalid';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private docToInterface(doc: EmailDomainDoc): IEmailDomain {
|
||||
return {
|
||||
id: doc.id,
|
||||
domain: doc.domain,
|
||||
linkedDomainId: doc.linkedDomainId,
|
||||
subdomain: doc.subdomain,
|
||||
dkim: doc.dkim,
|
||||
rateLimits: doc.rateLimits,
|
||||
dnsStatus: doc.dnsStatus,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private isDomainAlreadyConfigured(domainName: string): boolean {
|
||||
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
.map((domainConfig) => domainConfig.domain.toLowerCase());
|
||||
return configuredDomains.includes(domainName.toLowerCase());
|
||||
}
|
||||
|
||||
private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
|
||||
const domains = await DomainDoc.findAll();
|
||||
return domains
|
||||
.filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
|
||||
.sort((a, b) => b.name.length - a.name.length)[0] || null;
|
||||
}
|
||||
|
||||
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
const managedConfigs: IEmailDomainConfig[] = [];
|
||||
|
||||
for (const doc of docs) {
|
||||
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
|
||||
if (!linkedDomain) {
|
||||
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
managedConfigs.push({
|
||||
domain: doc.domain,
|
||||
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
|
||||
dkim: {
|
||||
selector: doc.dkim.selector,
|
||||
keySize: doc.dkim.keySize,
|
||||
rotateKeys: doc.dkim.rotateKeys,
|
||||
rotationInterval: doc.dkim.rotationIntervalDays,
|
||||
},
|
||||
rateLimits: doc.rateLimits,
|
||||
});
|
||||
}
|
||||
|
||||
return managedConfigs;
|
||||
}
|
||||
|
||||
public async syncManagedDomainsToRuntime(): Promise<void> {
|
||||
if (!this.dcRouter.options?.emailConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
||||
for (const domainConfig of this.baseEmailDomains) {
|
||||
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
for (const managedConfig of await this.buildManagedDomainConfigs()) {
|
||||
const key = managedConfig.domain.toLowerCase();
|
||||
if (mergedDomains.has(key)) {
|
||||
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
|
||||
continue;
|
||||
}
|
||||
mergedDomains.set(key, managedConfig);
|
||||
}
|
||||
|
||||
const domains = Array.from(mergedDomains.values());
|
||||
this.dcRouter.options.emailConfig.domains = domains;
|
||||
if (this.dcRouter.emailServer) {
|
||||
this.dcRouter.emailServer.updateOptions({ domains });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IStorageManagerLike } from '@push.rocks/smartmta';
|
||||
|
||||
export class SmartMtaStorageManager implements IStorageManagerLike {
|
||||
private readonly resolvedRootDir: string;
|
||||
|
||||
constructor(private rootDir: string) {
|
||||
this.resolvedRootDir = plugins.path.resolve(rootDir);
|
||||
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
|
||||
}
|
||||
|
||||
private normalizeKey(key: string): string {
|
||||
return key.replace(/^\/+/, '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
private resolvePathForKey(key: string): string {
|
||||
const normalizedKey = this.normalizeKey(key);
|
||||
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
|
||||
if (
|
||||
resolvedPath !== this.resolvedRootDir
|
||||
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
|
||||
) {
|
||||
throw new Error(`Storage key escapes root directory: ${key}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
private toStorageKey(filePath: string): string {
|
||||
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
|
||||
return `/${relativePath}`;
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
const filePath = this.resolvePathForKey(key);
|
||||
try {
|
||||
return await plugins.fs.promises.readFile(filePath, 'utf8');
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async set(key: string, value: string): Promise<void> {
|
||||
const filePath = this.resolvePathForKey(key);
|
||||
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
|
||||
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
|
||||
}
|
||||
|
||||
public async list(prefix: string): Promise<string[]> {
|
||||
const prefixPath = this.resolvePathForKey(prefix);
|
||||
try {
|
||||
const stat = await plugins.fs.promises.stat(prefixPath);
|
||||
if (stat.isFile()) {
|
||||
return [this.toStorageKey(prefixPath)];
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
const walk = async (currentPath: string): Promise<void> => {
|
||||
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = plugins.path.join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(this.toStorageKey(entryPath));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(prefixPath);
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
public async delete(key: string): Promise<void> {
|
||||
const targetPath = this.resolvePathForKey(key);
|
||||
try {
|
||||
const stat = await plugins.fs.promises.stat(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
|
||||
} else {
|
||||
await plugins.fs.promises.unlink(targetPath);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let currentDir = plugins.path.dirname(targetPath);
|
||||
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
|
||||
const entries = await plugins.fs.promises.readdir(currentDir);
|
||||
if (entries.length > 0) {
|
||||
break;
|
||||
}
|
||||
await plugins.fs.promises.rmdir(currentDir);
|
||||
currentDir = plugins.path.dirname(currentDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
IEmailDnsRecord,
|
||||
TDnsRecordStatus,
|
||||
} from '../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
|
||||
|
||||
export interface IBuildEmailDnsRecordsOptions {
|
||||
domain: string;
|
||||
hostname: string;
|
||||
selector?: string;
|
||||
dkimValue?: string;
|
||||
mxPriority?: number;
|
||||
dmarcPolicy?: string;
|
||||
dmarcRua?: string;
|
||||
statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
|
||||
}
|
||||
|
||||
export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
|
||||
const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
|
||||
const selector = options.selector || 'default';
|
||||
const records: IEmailDnsRecord[] = [
|
||||
{
|
||||
type: 'MX',
|
||||
name: options.domain,
|
||||
value: `${options.mxPriority ?? 10} ${options.hostname}`,
|
||||
status: statusFor('mx'),
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: options.domain,
|
||||
value: 'v=spf1 a mx ~all',
|
||||
status: statusFor('spf'),
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `_dmarc.${options.domain}`,
|
||||
value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
|
||||
status: statusFor('dmarc'),
|
||||
},
|
||||
];
|
||||
|
||||
if (options.dkimValue) {
|
||||
records.splice(2, 0, {
|
||||
type: 'TXT',
|
||||
name: `${selector}._domainkey.${options.domain}`,
|
||||
value: options.dkimValue,
|
||||
status: statusFor('dkim'),
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
@@ -0,0 +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';
|
||||
@@ -553,12 +553,16 @@ export class MetricsManager {
|
||||
connectionsByIP: new Map<string, number>(),
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [] as Array<{ ip: string; count: number }>,
|
||||
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [] as Array<any>,
|
||||
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number; requestsPerSecond?: number; requestsLastMinute?: number }>,
|
||||
frontendProtocols: null,
|
||||
backendProtocols: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -572,7 +576,7 @@ export class MetricsManager {
|
||||
bytesOutPerSecond: instantThroughput.out
|
||||
};
|
||||
|
||||
// Get top IPs
|
||||
// Get top IPs by connection count
|
||||
const topIPs = proxyMetrics.connections.topIPs(10);
|
||||
|
||||
// Get total data transferred
|
||||
@@ -590,6 +594,7 @@ export class MetricsManager {
|
||||
// Get HTTP request rates
|
||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||
const requestsTotal = proxyMetrics.requests.total();
|
||||
const domainRequestRates = proxyMetrics.requests.byDomain();
|
||||
|
||||
// Get frontend/backend protocol distribution
|
||||
const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null;
|
||||
@@ -617,47 +622,48 @@ export class MetricsManager {
|
||||
const seenCacheKeys = new Set<string>();
|
||||
|
||||
for (const [key, bm] of backendMetrics) {
|
||||
backends.push({
|
||||
id: `backend:${key}`,
|
||||
backend: key,
|
||||
domain: null,
|
||||
protocol: bm.protocol,
|
||||
activeConnections: bm.activeConnections,
|
||||
totalConnections: bm.totalConnections,
|
||||
connectErrors: bm.connectErrors,
|
||||
handshakeErrors: bm.handshakeErrors,
|
||||
requestErrors: bm.requestErrors,
|
||||
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||
h2Failures: bm.h2Failures,
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
cacheAgeSecs: null,
|
||||
});
|
||||
|
||||
const cacheEntries = cacheByBackend.get(key);
|
||||
if (!cacheEntries || cacheEntries.length === 0) {
|
||||
// No protocol cache entry — emit one row with backend metrics only
|
||||
backends.push({
|
||||
backend: key,
|
||||
domain: null,
|
||||
protocol: bm.protocol,
|
||||
activeConnections: bm.activeConnections,
|
||||
totalConnections: bm.totalConnections,
|
||||
connectErrors: bm.connectErrors,
|
||||
handshakeErrors: bm.handshakeErrors,
|
||||
requestErrors: bm.requestErrors,
|
||||
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||
h2Failures: bm.h2Failures,
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
cacheAgeSecs: null,
|
||||
});
|
||||
} else {
|
||||
// One row per domain, each enriched with the shared backend metrics
|
||||
if (cacheEntries && cacheEntries.length > 0) {
|
||||
// Protocol cache rows are domain-scoped metadata, not live backend connections.
|
||||
for (const cache of cacheEntries) {
|
||||
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
|
||||
seenCacheKeys.add(compositeKey);
|
||||
backends.push({
|
||||
id: `cache:${compositeKey}`,
|
||||
backend: key,
|
||||
domain: cache.domain ?? null,
|
||||
protocol: cache.protocol ?? bm.protocol,
|
||||
activeConnections: bm.activeConnections,
|
||||
totalConnections: bm.totalConnections,
|
||||
connectErrors: bm.connectErrors,
|
||||
handshakeErrors: bm.handshakeErrors,
|
||||
requestErrors: bm.requestErrors,
|
||||
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||
h2Failures: bm.h2Failures,
|
||||
activeConnections: 0,
|
||||
totalConnections: 0,
|
||||
connectErrors: 0,
|
||||
handshakeErrors: 0,
|
||||
requestErrors: 0,
|
||||
avgConnectTimeMs: 0,
|
||||
poolHitRate: 0,
|
||||
h2Failures: 0,
|
||||
h2Suppressed: cache.h2Suppressed,
|
||||
h3Suppressed: cache.h3Suppressed,
|
||||
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
|
||||
@@ -676,6 +682,7 @@ export class MetricsManager {
|
||||
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
|
||||
if (!seenCacheKeys.has(compositeKey)) {
|
||||
backends.push({
|
||||
id: `cache:${compositeKey}`,
|
||||
backend: `${entry.host}:${entry.port}`,
|
||||
domain: entry.domain,
|
||||
protocol: entry.protocol,
|
||||
@@ -699,10 +706,169 @@ export class MetricsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Build top 10 IPs by bandwidth (sorted by total throughput desc)
|
||||
const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
|
||||
for (const [ip, count] of connectionsByIP) {
|
||||
allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
|
||||
}
|
||||
for (const [ip, tp] of throughputByIP) {
|
||||
const existing = allIPData.get(ip);
|
||||
if (existing) {
|
||||
existing.bwIn = tp.in;
|
||||
existing.bwOut = tp.out;
|
||||
} else {
|
||||
allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
|
||||
}
|
||||
}
|
||||
const topIPsByBandwidth = Array.from(allIPData.entries())
|
||||
.sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
|
||||
.slice(0, 10)
|
||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||
|
||||
void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
|
||||
|
||||
// Build domain activity using per-IP domain request counts from Rust engine
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const throughputByRoute = proxyMetrics.throughput.byRoute();
|
||||
|
||||
// Aggregate per-IP domain request counts into per-domain totals
|
||||
const domainRequestTotals = new Map<string, number>();
|
||||
const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP();
|
||||
for (const [, domainMap] of domainRequestsByIP) {
|
||||
for (const [domain, count] of domainMap) {
|
||||
domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count);
|
||||
}
|
||||
}
|
||||
|
||||
// Map canonical route key → domains from route config
|
||||
const routeDomains = new Map<string, string[]>();
|
||||
if (this.dcRouter.smartProxy) {
|
||||
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
||||
const routeKey = route.name || route.id;
|
||||
if (!routeKey || !route.match.domains) continue;
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
if (domains.length > 0) {
|
||||
routeDomains.set(routeKey, domains);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve wildcards using domains seen in request metrics
|
||||
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
|
||||
for (const domain of domainRequestRates.keys()) {
|
||||
allKnownDomains.add(domain);
|
||||
}
|
||||
for (const entry of protocolCache) {
|
||||
if (entry.domain) allKnownDomains.add(entry.domain);
|
||||
}
|
||||
|
||||
// Build reverse map: concrete domain → canonical route key(s)
|
||||
const domainToRoutes = new Map<string, string[]>();
|
||||
for (const [routeKey, domains] of routeDomains) {
|
||||
for (const pattern of domains) {
|
||||
if (pattern.includes('*')) {
|
||||
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
||||
for (const knownDomain of allKnownDomains) {
|
||||
if (regex.test(knownDomain)) {
|
||||
const existing = domainToRoutes.get(knownDomain);
|
||||
if (existing) { existing.push(routeKey); }
|
||||
else { domainToRoutes.set(knownDomain, [routeKey]); }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const existing = domainToRoutes.get(pattern);
|
||||
if (existing) { existing.push(routeKey); }
|
||||
else { domainToRoutes.set(pattern, [routeKey]); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasLiveDomainRates = domainRequestRates.size > 0;
|
||||
const getDomainWeight = (domain: string): number => {
|
||||
const liveRate = domainRequestRates.get(domain);
|
||||
return hasLiveDomainRates
|
||||
? (liveRate?.lastMinute ?? 0)
|
||||
: (domainRequestTotals.get(domain) || 0);
|
||||
};
|
||||
|
||||
// For each route, compute the total activity weight across all resolved domains
|
||||
// so we can distribute route-level throughput/connections. Prefer live domain
|
||||
// request rates from SmartProxy 27.8+, falling back to lifetime counters.
|
||||
const routeTotalRequests = new Map<string, number>();
|
||||
for (const [domain, routeKeys] of domainToRoutes) {
|
||||
const reqs = getDomainWeight(domain);
|
||||
for (const routeKey of routeKeys) {
|
||||
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate metrics per domain using request-count-proportional splitting
|
||||
const domainAgg = new Map<string, {
|
||||
activeConnections: number;
|
||||
bytesInPerSec: number;
|
||||
bytesOutPerSec: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
requestsPerSecond: number;
|
||||
requestsLastMinute: number;
|
||||
}>();
|
||||
|
||||
for (const [domain, routeKeys] of domainToRoutes) {
|
||||
const domainReqs = getDomainWeight(domain);
|
||||
const requestRate = domainRequestRates.get(domain);
|
||||
let totalConns = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
|
||||
for (const routeKey of routeKeys) {
|
||||
const conns = connectionsByRoute.get(routeKey) || 0;
|
||||
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
|
||||
const routeTotal = routeTotalRequests.get(routeKey) || 0;
|
||||
|
||||
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
||||
totalConns += conns * share;
|
||||
totalIn += tp.in * share;
|
||||
totalOut += tp.out * share;
|
||||
}
|
||||
|
||||
domainAgg.set(domain, {
|
||||
activeConnections: Math.round(totalConns),
|
||||
bytesInPerSec: totalIn,
|
||||
bytesOutPerSec: totalOut,
|
||||
routeCount: routeKeys.length,
|
||||
requestCount: domainRequestTotals.get(domain) || 0,
|
||||
requestsPerSecond: requestRate?.perSecond ?? 0,
|
||||
requestsLastMinute: requestRate?.lastMinute ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const domainActivity = Array.from(domainAgg.entries())
|
||||
.map(([domain, data]) => ({
|
||||
domain,
|
||||
bytesInPerSecond: data.bytesInPerSec,
|
||||
bytesOutPerSecond: data.bytesOutPerSec,
|
||||
activeConnections: data.activeConnections,
|
||||
routeCount: data.routeCount,
|
||||
requestCount: data.requestCount,
|
||||
requestsPerSecond: data.requestsPerSecond,
|
||||
requestsLastMinute: data.requestsLastMinute,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (hasLiveDomainRates) {
|
||||
return (b.requestsPerSecond - a.requestsPerSecond) ||
|
||||
(b.requestsLastMinute - a.requestsLastMinute) ||
|
||||
((b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
||||
}
|
||||
return (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond);
|
||||
});
|
||||
|
||||
return {
|
||||
connectionsByIP,
|
||||
throughputRate,
|
||||
topIPs,
|
||||
topIPsByBandwidth,
|
||||
totalDataTransferred,
|
||||
throughputHistory,
|
||||
throughputByIP,
|
||||
@@ -711,6 +877,7 @@ export class MetricsManager {
|
||||
backends,
|
||||
frontendProtocols,
|
||||
backendProtocols,
|
||||
domainActivity,
|
||||
};
|
||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||
}
|
||||
@@ -857,4 +1024,4 @@ export class MetricsManager {
|
||||
|
||||
return { queries };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export class OpsServer {
|
||||
private domainHandler!: handlers.DomainHandler;
|
||||
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||
private emailDomainHandler!: handlers.EmailDomainHandler;
|
||||
private workHosterHandler!: handlers.WorkHosterHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -104,6 +106,8 @@ export class OpsServer {
|
||||
this.domainHandler = new handlers.DomainHandler(this);
|
||||
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');
|
||||
}
|
||||
@@ -117,4 +121,4 @@ export class OpsServer {
|
||||
await this.server.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,14 +43,21 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,4 +256,4 @@ export class AdminHandler {
|
||||
name: 'adminIdentityGuard',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export class ApiTokenHandler {
|
||||
dataArg.scopes,
|
||||
dataArg.expiresInDays ?? null,
|
||||
dataArg.identity.userId,
|
||||
dataArg.policy,
|
||||
);
|
||||
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||
},
|
||||
|
||||
@@ -26,21 +26,51 @@ 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> {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||
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');
|
||||
}
|
||||
|
||||
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 +78,56 @@ export class CertificateHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||
|
||||
// Legacy route-based reprovision (backward compat)
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||
'reprovisionCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.reprovisionCertificateByRoute(dataArg.routeName);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Domain-based reprovision (preferred)
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
'reprovisionCertificateDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Delete certificate
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||
'deleteCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.deleteCertificate(dataArg.domain);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Export certificate
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||
'exportCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:read');
|
||||
return this.exportCertificate(dataArg.domain);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Import certificate
|
||||
adminRouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||
'importCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.importCertificate(dataArg.cert);
|
||||
}
|
||||
)
|
||||
@@ -198,12 +231,11 @@ export class CertificateHandler {
|
||||
try {
|
||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||
if (rustStatus) {
|
||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
||||
status = rustStatus.status;
|
||||
if (rustStatus.expiresAt > 0) {
|
||||
expiryDate = new Date(rustStatus.expiresAt).toISOString();
|
||||
}
|
||||
if (rustStatus.source) issuer = rustStatus.source;
|
||||
status = rustStatus.isValid ? 'valid' : 'expired';
|
||||
}
|
||||
} catch {
|
||||
// Rust bridge may not support this command yet — ignore
|
||||
|
||||
@@ -127,7 +127,7 @@ export class ConfigHandler {
|
||||
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
|
||||
let dnsChallengeEnabled = false;
|
||||
try {
|
||||
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false;
|
||||
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
|
||||
} catch {
|
||||
dnsChallengeEnabled = false;
|
||||
}
|
||||
@@ -206,6 +206,7 @@ export class ConfigHandler {
|
||||
hubDomain: riCfg?.hubDomain || null,
|
||||
tlsMode,
|
||||
connectedEdgeIps,
|
||||
performance: riCfg?.performance,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -157,5 +157,23 @@ export class DomainHandler {
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Migrate domain between dcrouter-hosted and provider-managed
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MigrateDomain>(
|
||||
'migrateDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.migrateDomain({
|
||||
id: dataArg.id,
|
||||
targetSource: dataArg.targetSource,
|
||||
targetProviderId: dataArg.targetProviderId,
|
||||
deleteExistingProviderRecords: dataArg.deleteExistingProviderRecords,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD + DNS provisioning handler for email domains.
|
||||
*
|
||||
* Auth: admin JWT or API token with `email-domains:read` / `email-domains:write` scope.
|
||||
*/
|
||||
export class EmailDomainHandler {
|
||||
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<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');
|
||||
}
|
||||
|
||||
private get manager() {
|
||||
return this.opsServerRef.dcRouterRef.emailDomainManager;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// List all email domains
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomains>(
|
||||
'getEmailDomains',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { domains: [] };
|
||||
return { domains: await this.manager.getAll() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomain>(
|
||||
'getEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { domain: null };
|
||||
return { domain: await this.manager.getById(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateEmailDomain>(
|
||||
'createEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const domain = await this.manager.createEmailDomain({
|
||||
linkedDomainId: dataArg.linkedDomainId,
|
||||
subdomain: dataArg.subdomain,
|
||||
dkimSelector: dataArg.dkimSelector,
|
||||
dkimKeySize: dataArg.dkimKeySize,
|
||||
rotateKeys: dataArg.rotateKeys,
|
||||
rotationIntervalDays: dataArg.rotationIntervalDays,
|
||||
});
|
||||
return { success: true, domain };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailDomain>(
|
||||
'updateEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
await this.manager.updateEmailDomain(dataArg.id, {
|
||||
rotateKeys: dataArg.rotateKeys,
|
||||
rotationIntervalDays: dataArg.rotationIntervalDays,
|
||||
rateLimits: dataArg.rateLimits,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteEmailDomain>(
|
||||
'deleteEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
await this.manager.deleteEmailDomain(dataArg.id);
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Validate DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ValidateEmailDomain>(
|
||||
'validateEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const records = await this.manager.validateDns(dataArg.id);
|
||||
const domain = await this.manager.getById(dataArg.id);
|
||||
return { success: true, domain: domain ?? undefined, records };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get required DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomainDnsRecords>(
|
||||
'getEmailDomainDnsRecords',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { records: [] };
|
||||
return { records: await this.manager.getRequiredDnsRecords(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Auto-provision DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ProvisionEmailDomainDns>(
|
||||
'provisionEmailDomainDns',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const provisioned = await this.manager.provisionDnsRecords(dataArg.id);
|
||||
return { success: true, provisioned };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(dataArg.emailId);
|
||||
const item = emailServer.getQueueItem(dataArg.emailId);
|
||||
|
||||
if (!item) {
|
||||
return { success: false, error: 'Email not found in queue' };
|
||||
@@ -82,22 +82,10 @@ export class EmailOpsHandler {
|
||||
*/
|
||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
if (!emailServer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const queueMap = (queue as any).queue as Map<string, any>;
|
||||
|
||||
if (!queueMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
for (const [id, item] of queueMap.entries()) {
|
||||
emails.push(this.mapQueueItemToEmail(item));
|
||||
}
|
||||
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
@@ -110,12 +98,10 @@ export class EmailOpsHandler {
|
||||
*/
|
||||
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
if (!emailServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(emailId);
|
||||
const item = emailServer.getQueueItem(emailId);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
|
||||
@@ -17,4 +17,6 @@ export * from './users.handler.js';
|
||||
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 './acme-config.handler.js';
|
||||
export * from './email-domain.handler.js';
|
||||
export * from './workhoster.handler.js';
|
||||
|
||||
@@ -135,7 +135,7 @@ export class NetworkTargetHandler {
|
||||
const result = await resolver.deleteTarget(
|
||||
dataArg.id,
|
||||
dataArg.force ?? false,
|
||||
manager.getStoredRoutes(),
|
||||
manager.getRoutes(),
|
||||
);
|
||||
|
||||
if (result.success && dataArg.force) {
|
||||
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
|
||||
if (!resolver || !manager) {
|
||||
return { routes: [] };
|
||||
}
|
||||
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getRoutes());
|
||||
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -29,6 +29,7 @@ export class RemoteIngressHandler {
|
||||
...e,
|
||||
secret: '********', // Never expose secrets via API
|
||||
effectiveListenPorts: manager.getEffectiveListenPorts(e),
|
||||
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(e),
|
||||
manualPorts: breakdown.manual,
|
||||
derivedPorts: breakdown.derived,
|
||||
};
|
||||
@@ -133,6 +134,7 @@ export class RemoteIngressHandler {
|
||||
...edge,
|
||||
secret: '********',
|
||||
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
|
||||
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(edge),
|
||||
manualPorts: breakdown.manual,
|
||||
derivedPorts: breakdown.derived,
|
||||
},
|
||||
|
||||
@@ -72,7 +72,7 @@ export class RouteManagementHandler {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
|
||||
return { success: true, storedRouteId: id };
|
||||
return { success: true, routeId: id };
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -87,12 +87,12 @@ export class RouteManagementHandler {
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.updateRoute(dataArg.id, {
|
||||
const result = await manager.updateRoute(dataArg.id, {
|
||||
route: dataArg.route as any,
|
||||
enabled: dataArg.enabled,
|
||||
metadata: dataArg.metadata,
|
||||
});
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -107,45 +107,12 @@ export class RouteManagementHandler {
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.deleteRoute(dataArg.id);
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
return manager.deleteRoute(dataArg.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Set override on a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Remove override from a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.removeOverride(dataArg.routeName);
|
||||
return { success: ok, message: ok ? undefined : 'Override not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Toggle programmatic route
|
||||
// Toggle route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
@@ -155,8 +122,7 @@ export class RouteManagementHandler {
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
return manager.toggleRoute(dataArg.id, dataArg.enabled);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -50,19 +50,21 @@ export class SecurityHandler {
|
||||
localAddress: conn.destination.ip,
|
||||
startTime: conn.startTime,
|
||||
protocol: conn.type === 'http' ? 'https' : conn.type as any,
|
||||
state: conn.status as any,
|
||||
bytesReceived: Math.floor(conn.bytesTransferred / 2),
|
||||
bytesSent: Math.floor(conn.bytesTransferred / 2),
|
||||
state: conn.status === 'active' ? 'connected' : conn.status as any,
|
||||
bytesReceived: (conn as any)._throughputIn || 0,
|
||||
bytesSent: (conn as any)._throughputOut || 0,
|
||||
connectionCount: conn.bytesTransferred || 1,
|
||||
}));
|
||||
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
|
||||
|
||||
const summary = {
|
||||
total: connectionInfos.length,
|
||||
total: totalConnections,
|
||||
byProtocol: connectionInfos.reduce((acc, conn) => {
|
||||
acc[conn.protocol] = (acc[conn.protocol] || 0) + 1;
|
||||
acc[conn.protocol] = (acc[conn.protocol] || 0) + (conn.connectionCount || 1);
|
||||
return acc;
|
||||
}, {} as { [protocol: string]: number }),
|
||||
byState: connectionInfos.reduce((acc, conn) => {
|
||||
acc[conn.state] = (acc[conn.state] || 0) + 1;
|
||||
acc[conn.state] = (acc[conn.state] || 0) + (conn.connectionCount || 1);
|
||||
return acc;
|
||||
}, {} as { [state: string]: number }),
|
||||
};
|
||||
@@ -96,12 +98,16 @@ export class SecurityHandler {
|
||||
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
||||
throughputRate: networkStats.throughputRate,
|
||||
topIPs: networkStats.topIPs,
|
||||
topIPsByBandwidth: networkStats.topIPsByBandwidth,
|
||||
totalDataTransferred: networkStats.totalDataTransferred,
|
||||
throughputHistory: networkStats.throughputHistory || [],
|
||||
throughputByIP,
|
||||
domainActivity: networkStats.domainActivity || [],
|
||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||
requestsTotal: networkStats.requestsTotal || 0,
|
||||
backends: networkStats.backends || [],
|
||||
frontendProtocols: networkStats.frontendProtocols || null,
|
||||
backendProtocols: networkStats.backendProtocols || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,12 +116,16 @@ export class SecurityHandler {
|
||||
connectionsByIP: [],
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
topIPsByBandwidth: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [],
|
||||
throughputByIP: [],
|
||||
domainActivity: [],
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [],
|
||||
frontendProtocols: null,
|
||||
backendProtocols: null,
|
||||
};
|
||||
}
|
||||
)
|
||||
@@ -147,6 +157,113 @@ export class SecurityHandler {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityBlockRules>(
|
||||
'listSecurityBlockRules',
|
||||
async () => {
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
return { rules: manager ? await manager.listBlockRules() : [] };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListIpIntelligence>(
|
||||
'listIpIntelligence',
|
||||
async () => {
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
return { records: manager ? await manager.listIpIntelligence() : [] };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCompiledSecurityPolicy>(
|
||||
'getCompiledSecurityPolicy',
|
||||
async () => {
|
||||
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) => {
|
||||
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 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,
|
||||
}, dataArg.identity.userId);
|
||||
return { success: true, rule };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityBlockRule>(
|
||||
'updateSecurityBlockRule',
|
||||
async (dataArg) => {
|
||||
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,
|
||||
}, dataArg.identity.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 manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
|
||||
const success = await manager.deleteBlockRule(dataArg.id, dataArg.identity.userId);
|
||||
return { success, message: success ? undefined : 'Rule not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshIpIntelligence>(
|
||||
'refreshIpIntelligence',
|
||||
async (dataArg) => {
|
||||
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<{
|
||||
@@ -251,31 +368,31 @@ export class SecurityHandler {
|
||||
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
|
||||
// Use IP-based connection data from the new metrics API
|
||||
// One aggregate row per IP with real throughput data
|
||||
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
|
||||
let connIndex = 0;
|
||||
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
|
||||
|
||||
|
||||
for (const [ip, count] of networkStats.connectionsByIP) {
|
||||
// Create a connection entry for each active IP connection
|
||||
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
|
||||
connections.push({
|
||||
id: `conn-${connIndex++}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: ip,
|
||||
port: Math.floor(Math.random() * 50000) + 10000, // High port range
|
||||
},
|
||||
destination: {
|
||||
ip: publicIp,
|
||||
port: 443,
|
||||
service: 'proxy',
|
||||
},
|
||||
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
|
||||
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
const tp = networkStats.throughputByIP?.get(ip);
|
||||
connections.push({
|
||||
id: `ip-${connIndex++}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: ip,
|
||||
port: 0,
|
||||
},
|
||||
destination: {
|
||||
ip: publicIp,
|
||||
port: 443,
|
||||
service: 'proxy',
|
||||
},
|
||||
startTime: 0,
|
||||
bytesTransferred: count, // Store connection count here
|
||||
status: 'active',
|
||||
// Attach real throughput for the handler mapping
|
||||
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
|
||||
} as any);
|
||||
}
|
||||
} else if (connectionInfo.length > 0) {
|
||||
// Fallback to route-based connection info if no IP data available
|
||||
@@ -331,4 +448,4 @@ export class SecurityHandler {
|
||||
limits: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export class SourceProfileHandler {
|
||||
const result = await resolver.deleteProfile(
|
||||
dataArg.id,
|
||||
dataArg.force ?? false,
|
||||
manager.getStoredRoutes(),
|
||||
manager.getRoutes(),
|
||||
);
|
||||
|
||||
// If force-deleted with affected routes, re-apply
|
||||
@@ -160,7 +160,7 @@ export class SourceProfileHandler {
|
||||
if (!resolver || !manager) {
|
||||
return { routes: [] };
|
||||
}
|
||||
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getRoutes());
|
||||
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -291,6 +291,21 @@ export class StatsHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Build connectionDetails from real per-IP data
|
||||
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
|
||||
for (const [ip, count] of stats.connectionsByIP) {
|
||||
const tp = stats.throughputByIP?.get(ip);
|
||||
connectionDetails.push({
|
||||
remoteAddress: ip,
|
||||
protocol: 'https',
|
||||
state: 'connected',
|
||||
startTime: 0,
|
||||
bytesIn: tp?.in || 0,
|
||||
bytesOut: tp?.out || 0,
|
||||
connectionCount: count,
|
||||
});
|
||||
}
|
||||
|
||||
metrics.network = {
|
||||
totalBandwidth: {
|
||||
in: stats.throughputRate.bytesInPerSecond,
|
||||
@@ -301,12 +316,18 @@ export class StatsHandler {
|
||||
out: stats.totalDataTransferred.bytesOut,
|
||||
},
|
||||
activeConnections: serverStats.activeConnections,
|
||||
connectionDetails: [],
|
||||
connectionDetails,
|
||||
topEndpoints: stats.topIPs.map(ip => ({
|
||||
endpoint: ip.ip,
|
||||
requests: ip.count,
|
||||
connections: ip.count,
|
||||
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
|
||||
})),
|
||||
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
|
||||
endpoint: ip.ip,
|
||||
connections: ip.count,
|
||||
bandwidth: { in: ip.bwIn, out: ip.bwOut },
|
||||
})),
|
||||
domainActivity: stats.domainActivity || [],
|
||||
throughputHistory: stats.throughputHistory || [],
|
||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||
requestsTotal: stats.requestsTotal || 0,
|
||||
@@ -510,13 +531,49 @@ export class StatsHandler {
|
||||
nextRetry?: number;
|
||||
}>;
|
||||
}> {
|
||||
// TODO: Implement actual queue status collection
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer) {
|
||||
return {
|
||||
pending: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const queueStats = emailServer.getQueueStats();
|
||||
const items = emailServer.getQueueItems()
|
||||
.sort((a, b) => {
|
||||
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
|
||||
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
|
||||
return right - left;
|
||||
})
|
||||
.slice(0, 50)
|
||||
.map((item) => {
|
||||
const emailLike = item.processingResult;
|
||||
const recipients = Array.isArray(emailLike?.to)
|
||||
? emailLike.to
|
||||
: Array.isArray(emailLike?.email?.to)
|
||||
? emailLike.email.to
|
||||
: [];
|
||||
const subject = emailLike?.subject || emailLike?.email?.subject || '';
|
||||
return {
|
||||
id: item.id,
|
||||
recipient: recipients[0] || '',
|
||||
subject,
|
||||
status: item.status,
|
||||
attempts: item.attempts,
|
||||
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
pending: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
items: [],
|
||||
pending: queueStats.status.pending,
|
||||
active: queueStats.status.processing,
|
||||
failed: queueStats.status.failed,
|
||||
retrying: queueStats.status.deferred,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -580,4 +637,4 @@ export class StatsHandler {
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.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> {
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return { userId: request.identity.userId, isAdmin: true };
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return { userId: token.createdBy, isAdmin: token.policy?.role === 'admin', token };
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
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_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 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 assertGatewayClientOwnership(auth: TAuthContext, ownership: interfaces.data.IGatewayClientOwnership): void {
|
||||
const policy = auth.token?.policy;
|
||||
if (!policy || policy.role !== 'gatewayClient') return;
|
||||
if (!policy.gatewayClient) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
|
||||
}
|
||||
if (ownership.gatewayClientType !== policy.gatewayClient.type || ownership.gatewayClientId !== policy.gatewayClient.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
|
||||
}
|
||||
if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
|
||||
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
this.assertGatewayClientOwnership(auth, ownership);
|
||||
this.assertRouteTargetsAllowed(auth, route);
|
||||
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
|
||||
const externalKey = this.buildGatewayClientExternalKey(ownership);
|
||||
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: ownership.gatewayClientType,
|
||||
gatewayClientId: ownership.gatewayClientId,
|
||||
gatewayClientAppId: ownership.appId,
|
||||
workHosterType: ownership.gatewayClientType,
|
||||
workHosterId: ownership.gatewayClientId,
|
||||
workAppId: ownership.appId,
|
||||
externalKey,
|
||||
};
|
||||
const normalizedRoute = this.normalizeGatewayClientRoute(route, ownership, 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: 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: 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;
|
||||
}
|
||||
}
|
||||
+44
-101
@@ -1,8 +1,6 @@
|
||||
# @serve.zone/dcrouter
|
||||
|
||||
The core DcRouter package — a unified datacenter gateway orchestrator. 🚀
|
||||
|
||||
This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
|
||||
The `ts/` directory is the main dcrouter runtime package. It exposes the `DcRouter` orchestrator, `IDcRouterOptions`, `runCli()`, and the server-side exports that matter when you want to boot the full router stack from code.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -14,7 +12,19 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
pnpm add @serve.zone/dcrouter
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Core Exports
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `DcRouter` | Main orchestrator for proxying, DNS, email, VPN, RADIUS, remote ingress, DB, and OpsServer |
|
||||
| `IDcRouterOptions` | Top-level configuration shape |
|
||||
| `runCli()` | Bootstrap helper; uses OCI env-driven config when `DCROUTER_MODE=OCI_CONTAINER` |
|
||||
| `UnifiedEmailServer` and smartmta types | Re-exported email server primitives |
|
||||
| `RadiusServer` and related types | RADIUS server runtime exports |
|
||||
| `RemoteIngressManager` and `TunnelManager` | Remote ingress orchestration exports |
|
||||
| `IHttp3Config` | HTTP/3 configuration for qualifying HTTPS routes |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
@@ -23,116 +33,49 @@ const router = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-app',
|
||||
match: { domains: ['example.com'], ports: [443] },
|
||||
name: 'local-app',
|
||||
match: {
|
||||
domains: ['localhost'],
|
||||
ports: [18080],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.10', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
}
|
||||
targets: [{ host: '127.0.0.1', port: 3001 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
|
||||
}
|
||||
},
|
||||
opsServerPort: 3000,
|
||||
});
|
||||
|
||||
await router.start();
|
||||
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
|
||||
|
||||
// Graceful shutdown
|
||||
await router.stop();
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
## What `DcRouter` Manages
|
||||
|
||||
```
|
||||
ts/
|
||||
├── index.ts # Main exports (DcRouter, re-exported smartmta types)
|
||||
├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions
|
||||
├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler
|
||||
├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager
|
||||
├── logger.ts # Structured logging utility
|
||||
├── paths.ts # Centralized data directory paths
|
||||
├── plugins.ts # All dependency imports
|
||||
├── cache/ # Cache database (smartdata + LocalTsmDb)
|
||||
│ ├── classes.cachedb.ts # CacheDb singleton
|
||||
│ ├── classes.cachecleaner.ts # TTL-based cleanup
|
||||
│ └── documents/ # Cached document models
|
||||
├── config/ # Configuration utilities
|
||||
├── errors/ # Error classes and retry logic
|
||||
├── http3/ # HTTP/3 (QUIC) route augmentation
|
||||
│ ├── index.ts # Barrel export
|
||||
│ └── http3-route-augmentation.ts # Pure utility: augmentRoutesWithHttp3(), IHttp3Config
|
||||
├── monitoring/ # MetricsManager (SmartMetrics integration)
|
||||
├── opsserver/ # OpsServer dashboard + API handlers
|
||||
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
|
||||
│ └── handlers/ # TypedRequest handlers by domain
|
||||
│ ├── admin.handler.ts # Auth (login/logout/verify)
|
||||
│ ├── stats.handler.ts # Statistics + health
|
||||
│ ├── config.handler.ts # Configuration (read-only)
|
||||
│ ├── logs.handler.ts # Log retrieval
|
||||
│ ├── email.handler.ts # Email operations
|
||||
│ ├── certificate.handler.ts # Certificate management
|
||||
│ ├── radius.handler.ts # RADIUS management
|
||||
│ ├── remoteingress.handler.ts # Remote ingress edge + token management
|
||||
│ ├── route-management.handler.ts # Programmatic route CRUD
|
||||
│ ├── api-token.handler.ts # API token management
|
||||
│ └── security.handler.ts # Security metrics + connections
|
||||
├── radius/ # RADIUS server integration
|
||||
├── remoteingress/ # Remote ingress hub integration
|
||||
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
|
||||
│ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
|
||||
├── security/ # Security utilities
|
||||
├── sms/ # SMS integration
|
||||
└── storage/ # StorageManager (filesystem/custom/memory)
|
||||
```
|
||||
- SmartProxy for HTTP/HTTPS/TCP routes
|
||||
- `UnifiedEmailServer` for SMTP ingress and delivery when `emailConfig` is present
|
||||
- DB-backed managers for routes, API tokens, target profiles, domains, records, ACME config, and email domains when the DB is enabled
|
||||
- embedded authoritative DNS and DoH route generation from `dnsNsDomains` and `dnsScopes`
|
||||
- VPN, RADIUS, and remote ingress services when their config blocks are enabled
|
||||
- OpsServer and the dashboard, which start on every boot
|
||||
|
||||
## Exports
|
||||
## Important Runtime Behavior
|
||||
|
||||
```typescript
|
||||
// Main class
|
||||
export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js';
|
||||
- The DB is enabled by default and uses an embedded local database when no external MongoDB URL is provided.
|
||||
- System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
|
||||
- API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
|
||||
- Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
|
||||
- `runCli()` is the supported code-level bootstrap entrypoint; the package does not expose a separate npm `bin` command.
|
||||
|
||||
// Re-exported from smartmta
|
||||
export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
## Use Another Module When...
|
||||
|
||||
// RADIUS
|
||||
export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
|
||||
|
||||
// Remote Ingress
|
||||
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
|
||||
// HTTP/3
|
||||
export type { IHttp3Config } from './http3/index.js';
|
||||
```
|
||||
|
||||
## Key Classes
|
||||
|
||||
### `DcRouter`
|
||||
|
||||
The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
|
||||
|
||||
| Config Section | Service Started | Package |
|
||||
|----------------|----------------|---------|
|
||||
| `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
|
||||
| `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
|
||||
| `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
|
||||
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
|
||||
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
|
||||
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
|
||||
| `http3` | HTTP/3 route augmentation (enabled by default) | built-in |
|
||||
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
|
||||
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
|
||||
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
|
||||
|
||||
### `RemoteIngressManager`
|
||||
|
||||
Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
|
||||
|
||||
### `TunnelManager`
|
||||
|
||||
Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
|
||||
| Need | Module |
|
||||
| --- | --- |
|
||||
| A higher-level client SDK for a running router | `@serve.zone/dcrouter-apiclient` or `@serve.zone/dcrouter/apiclient` |
|
||||
| Raw TypedRequest request/data contracts | `@serve.zone/dcrouter-interfaces` or `@serve.zone/dcrouter/interfaces` |
|
||||
| The standalone migration runner | `@serve.zone/dcrouter-migrations` |
|
||||
| The browser dashboard module boundary | `@serve.zone/dcrouter-web` |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ITunnelManagerConfig {
|
||||
certPem?: string;
|
||||
keyPem?: string;
|
||||
};
|
||||
performance?: import('../../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export class TunnelManager {
|
||||
private config: ITunnelManagerConfig;
|
||||
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
|
||||
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private syncChain: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
|
||||
this.manager = manager;
|
||||
@@ -66,7 +68,8 @@ export class TunnelManager {
|
||||
tunnelPort: this.config.tunnelPort ?? 8443,
|
||||
targetHost: this.config.targetHost ?? '127.0.0.1',
|
||||
tls: this.config.tls,
|
||||
});
|
||||
...(this.config.performance ? { performance: this.config.performance } : {}),
|
||||
} as any);
|
||||
|
||||
// Send allowed edges to the hub
|
||||
await this.syncAllowedEdges();
|
||||
@@ -107,20 +110,23 @@ export class TunnelManager {
|
||||
if (existing) {
|
||||
existing.activeTunnels = rustEdge.activeStreams;
|
||||
existing.lastHeartbeat = Date.now();
|
||||
this.applyRustStatus(existing, rustEdge);
|
||||
// Update peer address if available from Rust hub
|
||||
if (rustEdge.peerAddr) {
|
||||
existing.publicIp = rustEdge.peerAddr;
|
||||
}
|
||||
} else {
|
||||
// Missed edgeConnected event — add entry
|
||||
this.edgeStatuses.set(rustEdge.edgeId, {
|
||||
const status: IRemoteIngressStatus = {
|
||||
edgeId: rustEdge.edgeId,
|
||||
connected: true,
|
||||
publicIp: rustEdge.peerAddr || null,
|
||||
activeTunnels: rustEdge.activeStreams,
|
||||
lastHeartbeat: Date.now(),
|
||||
connectedAt: rustEdge.connectedAt * 1000,
|
||||
});
|
||||
};
|
||||
this.applyRustStatus(status, rustEdge);
|
||||
this.edgeStatuses.set(rustEdge.edgeId, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +143,22 @@ export class TunnelManager {
|
||||
* Call this after creating/deleting/updating edges.
|
||||
*/
|
||||
public async syncAllowedEdges(): Promise<void> {
|
||||
const edges = this.manager.getAllowedEdges();
|
||||
await this.hub.updateAllowedEdges(edges);
|
||||
const run = this.syncChain.catch(() => {}).then(async () => {
|
||||
const edges = this.manager.getAllowedEdges();
|
||||
await this.hub.updateAllowedEdges(edges as any);
|
||||
});
|
||||
this.syncChain = run;
|
||||
await run;
|
||||
}
|
||||
|
||||
private applyRustStatus(status: IRemoteIngressStatus, rustEdge: any): void {
|
||||
status.transportMode = rustEdge.transportMode;
|
||||
status.fallbackUsed = rustEdge.fallbackUsed;
|
||||
status.performance = rustEdge.performance;
|
||||
status.flowControl = rustEdge.flowControl;
|
||||
status.queues = rustEdge.queues;
|
||||
status.traffic = rustEdge.traffic;
|
||||
status.udp = rustEdge.udp;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
export class SecurityPolicyManager {
|
||||
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
|
||||
cacheTtl: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
private readonly intelligenceRefreshMs: number;
|
||||
private readonly inFlightObservations = new Set<string>();
|
||||
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> {
|
||||
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 async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
|
||||
const ip = this.normalizeIp(ipAddress);
|
||||
if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inFlightObservations.add(ip);
|
||||
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}`);
|
||||
} finally {
|
||||
this.inFlightObservations.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
public async listBlockRules(): Promise<ISecurityBlockRule[]> {
|
||||
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
|
||||
}
|
||||
|
||||
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
|
||||
return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(doc));
|
||||
}
|
||||
|
||||
public async 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;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
+202
-65
@@ -55,6 +55,8 @@ export class VpnManager {
|
||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||
private clients: Map<string, VpnClientDoc> = new Map();
|
||||
private serverKeys?: VpnServerKeysDoc;
|
||||
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||
|
||||
constructor(config: IVpnManagerConfig) {
|
||||
this.config = config;
|
||||
@@ -88,6 +90,7 @@ export class VpnManager {
|
||||
if (client.useHostIp) {
|
||||
anyClientUsesHostIp = true;
|
||||
}
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
const entry: plugins.smartvpn.IClientEntry = {
|
||||
clientId: client.clientId,
|
||||
publicKey: client.noisePublicKey,
|
||||
@@ -97,28 +100,26 @@ export class VpnManager {
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
security: this.buildClientSecurity(client),
|
||||
useHostIp: client.useHostIp,
|
||||
useDhcp: client.useDhcp,
|
||||
staticIp: client.staticIp,
|
||||
forceVlan: client.forceVlan,
|
||||
vlanId: client.vlanId,
|
||||
};
|
||||
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
||||
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
||||
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
||||
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
||||
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
||||
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
||||
clientEntries.push(entry);
|
||||
}
|
||||
|
||||
const subnet = this.getSubnet();
|
||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||
|
||||
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||
let configuredMode = this.config.forwardingMode ?? 'socket';
|
||||
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||
configuredMode = 'hybrid';
|
||||
const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
|
||||
if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
|
||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||
}
|
||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||
const forwardingMode = desiredForwardingMode;
|
||||
const isBridge = forwardingMode === 'bridge';
|
||||
this.resolvedForwardingMode = forwardingMode;
|
||||
this.forwardingModeOverride = undefined;
|
||||
|
||||
// Create and start VpnServer
|
||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||
@@ -143,7 +144,7 @@ export class VpnManager {
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
@@ -189,6 +190,7 @@ export class VpnManager {
|
||||
this.vpnServer.stop();
|
||||
this.vpnServer = undefined;
|
||||
}
|
||||
this.resolvedForwardingMode = undefined;
|
||||
logger.log('info', 'VPN server stopped');
|
||||
}
|
||||
|
||||
@@ -213,14 +215,38 @@ export class VpnManager {
|
||||
throw new Error('VPN server not running');
|
||||
}
|
||||
|
||||
await this.ensureForwardingModeForNextClient(opts.useHostIp === true);
|
||||
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = opts.clientId;
|
||||
doc.enabled = true;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = opts.description;
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
doc.destinationBlockList = opts.destinationBlockList;
|
||||
doc.useHostIp = opts.useHostIp;
|
||||
doc.useDhcp = opts.useDhcp;
|
||||
doc.staticIp = opts.staticIp;
|
||||
doc.forceVlan = opts.forceVlan;
|
||||
doc.vlanId = opts.vlanId;
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
|
||||
const bundle = await this.vpnServer.createClient({
|
||||
clientId: opts.clientId,
|
||||
description: opts.description,
|
||||
clientId: doc.clientId,
|
||||
description: doc.description,
|
||||
security: this.buildClientSecurity(doc),
|
||||
useHostIp: doc.useHostIp,
|
||||
useDhcp: doc.useDhcp,
|
||||
staticIp: doc.staticIp,
|
||||
forceVlan: doc.forceVlan,
|
||||
vlanId: doc.vlanId,
|
||||
});
|
||||
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
@@ -228,40 +254,16 @@ export class VpnManager {
|
||||
}
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = bundle.entry.clientId;
|
||||
doc.enabled = bundle.entry.enabled ?? true;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = bundle.entry.description;
|
||||
doc.assignedIp = bundle.entry.assignedIp;
|
||||
doc.noisePublicKey = bundle.entry.publicKey;
|
||||
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
doc.expiresAt = bundle.entry.expiresAt;
|
||||
if (opts.destinationAllowList !== undefined) {
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
}
|
||||
if (opts.destinationBlockList !== undefined) {
|
||||
doc.destinationBlockList = opts.destinationBlockList;
|
||||
}
|
||||
if (opts.useHostIp !== undefined) {
|
||||
doc.useHostIp = opts.useHostIp;
|
||||
}
|
||||
if (opts.useDhcp !== undefined) {
|
||||
doc.useDhcp = opts.useDhcp;
|
||||
}
|
||||
if (opts.staticIp !== undefined) {
|
||||
doc.staticIp = opts.staticIp;
|
||||
}
|
||||
if (opts.forceVlan !== undefined) {
|
||||
doc.forceVlan = opts.forceVlan;
|
||||
}
|
||||
if (opts.vlanId !== undefined) {
|
||||
doc.vlanId = opts.vlanId;
|
||||
}
|
||||
this.clients.set(doc.clientId, doc);
|
||||
try {
|
||||
await this.persistClient(doc);
|
||||
@@ -276,12 +278,6 @@ export class VpnManager {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
const security = this.buildClientSecurity(doc);
|
||||
if (security.destinationPolicy) {
|
||||
await this.vpnServer!.updateClient(doc.clientId, { security });
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
return bundle;
|
||||
}
|
||||
@@ -299,6 +295,7 @@ export class VpnManager {
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
await this.reconcileForwardingMode();
|
||||
this.config.onClientChanged?.();
|
||||
}
|
||||
|
||||
@@ -364,13 +361,15 @@ export class VpnManager {
|
||||
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
if (this.vpnServer) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
await this.vpnServer.updateClient(clientId, { security });
|
||||
const restarted = await this.reconcileForwardingMode();
|
||||
if (!restarted) {
|
||||
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
@@ -478,26 +477,28 @@ export class VpnManager {
|
||||
|
||||
/**
|
||||
* Build per-client security settings for the smartvpn daemon.
|
||||
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
|
||||
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
||||
* TargetProfile direct IP:port targets extend the effective allow-list.
|
||||
*/
|
||||
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||
const security: plugins.smartvpn.IClientSecurity = {};
|
||||
const basePolicy = this.getBaseDestinationPolicy(client);
|
||||
|
||||
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
|
||||
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||
|
||||
// Merge with per-client explicit allow list
|
||||
const mergedAllowList = [
|
||||
...(client.destinationAllowList || []),
|
||||
...profileDirectTargets,
|
||||
];
|
||||
const mergedAllowList = this.mergeDestinationLists(
|
||||
basePolicy.allowList,
|
||||
client.destinationAllowList,
|
||||
profileDirectTargets,
|
||||
);
|
||||
const mergedBlockList = this.mergeDestinationLists(
|
||||
basePolicy.blockList,
|
||||
client.destinationBlockList,
|
||||
);
|
||||
|
||||
security.destinationPolicy = {
|
||||
default: 'forceTarget' as const,
|
||||
target: '127.0.0.1',
|
||||
default: basePolicy.default,
|
||||
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||
blockList: client.destinationBlockList,
|
||||
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
||||
};
|
||||
|
||||
return security;
|
||||
@@ -510,10 +511,7 @@ export class VpnManager {
|
||||
public async refreshAllClientSecurity(): Promise<void> {
|
||||
if (!this.vpnServer) return;
|
||||
for (const client of this.clients.values()) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
if (security.destinationPolicy) {
|
||||
await this.vpnServer.updateClient(client.clientId, { security });
|
||||
}
|
||||
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +548,7 @@ export class VpnManager {
|
||||
private async loadPersistedClients(): Promise<void> {
|
||||
const docs = await VpnClientDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
this.clients.set(doc.clientId, doc);
|
||||
}
|
||||
if (this.clients.size > 0) {
|
||||
@@ -557,6 +556,144 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
||||
return this.resolvedForwardingMode
|
||||
?? this.forwardingModeOverride
|
||||
?? this.config.forwardingMode
|
||||
?? 'socket';
|
||||
}
|
||||
|
||||
private hasHostIpClients(extraHostIpClient = false): boolean {
|
||||
if (extraHostIpClient) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.useHostIp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private getDesiredForwardingMode(hasHostIpClients = this.hasHostIpClients()): 'socket' | 'bridge' | 'hybrid' {
|
||||
const configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||
if (configuredMode !== 'socket') {
|
||||
return configuredMode;
|
||||
}
|
||||
return hasHostIpClients ? 'hybrid' : 'socket';
|
||||
}
|
||||
|
||||
private getDefaultDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
useHostIp = false,
|
||||
): plugins.smartvpn.IDestinationPolicy {
|
||||
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
|
||||
return { default: 'allow' };
|
||||
}
|
||||
return { default: 'forceTarget', target: '127.0.0.1' };
|
||||
}
|
||||
|
||||
private getServerDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
|
||||
): plugins.smartvpn.IDestinationPolicy {
|
||||
return this.config.destinationPolicy ?? fallbackPolicy;
|
||||
}
|
||||
|
||||
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
|
||||
if (this.config.destinationPolicy) {
|
||||
return { ...this.config.destinationPolicy };
|
||||
}
|
||||
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
|
||||
}
|
||||
|
||||
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
|
||||
const merged = new Set<string>();
|
||||
for (const list of lists) {
|
||||
for (const entry of list || []) {
|
||||
merged.add(entry);
|
||||
}
|
||||
}
|
||||
return [...merged];
|
||||
}
|
||||
|
||||
private normalizeClientRoutingSettings(
|
||||
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
|
||||
): void {
|
||||
client.useHostIp = client.useHostIp === true;
|
||||
|
||||
if (!client.useHostIp) {
|
||||
client.useDhcp = false;
|
||||
client.staticIp = undefined;
|
||||
client.forceVlan = false;
|
||||
client.vlanId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
client.useDhcp = client.useDhcp === true;
|
||||
if (client.useDhcp) {
|
||||
client.staticIp = undefined;
|
||||
}
|
||||
|
||||
client.forceVlan = client.forceVlan === true;
|
||||
if (!client.forceVlan) {
|
||||
client.vlanId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
|
||||
return {
|
||||
description: client.description,
|
||||
security: this.buildClientSecurity(client),
|
||||
useHostIp: client.useHostIp,
|
||||
useDhcp: client.useDhcp,
|
||||
staticIp: client.staticIp,
|
||||
forceVlan: client.forceVlan,
|
||||
vlanId: client.vlanId,
|
||||
};
|
||||
}
|
||||
|
||||
private async restartWithForwardingMode(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
logger.log('info', `VPN: Restarting server in ${forwardingMode} mode ${reason}`);
|
||||
this.forwardingModeOverride = forwardingMode;
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private async ensureForwardingModeForNextClient(useHostIp: boolean): Promise<void> {
|
||||
if (!this.vpnServer) return;
|
||||
|
||||
const desiredForwardingMode = this.getDesiredForwardingMode(this.hasHostIpClients(useHostIp));
|
||||
if (desiredForwardingMode === this.getResolvedForwardingMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.restartWithForwardingMode(desiredForwardingMode, 'to support a host-IP client');
|
||||
}
|
||||
|
||||
private async reconcileForwardingMode(): Promise<boolean> {
|
||||
if (!this.vpnServer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desiredForwardingMode = this.getDesiredForwardingMode();
|
||||
const currentForwardingMode = this.getResolvedForwardingMode();
|
||||
if (desiredForwardingMode === currentForwardingMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reason = desiredForwardingMode === 'socket'
|
||||
? 'because no host-IP clients remain'
|
||||
: 'to support host-IP clients';
|
||||
await this.restartWithForwardingMode(desiredForwardingMode, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||
await client.save();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ConfigManager } from './classes.config.js';
|
||||
import { LogManager } from './classes.logs.js';
|
||||
import { EmailManager } from './classes.email.js';
|
||||
import { RadiusManager } from './classes.radius.js';
|
||||
import { WorkHosterManager } from './classes.workhoster.js';
|
||||
|
||||
export interface IDcRouterApiClientOptions {
|
||||
baseUrl: string;
|
||||
@@ -31,6 +32,7 @@ export class DcRouterApiClient {
|
||||
public logs: LogManager;
|
||||
public emails: EmailManager;
|
||||
public radius: RadiusManager;
|
||||
public workHosters: WorkHosterManager;
|
||||
|
||||
constructor(options: IDcRouterApiClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
@@ -45,6 +47,7 @@ export class DcRouterApiClient {
|
||||
this.logs = new LogManager(this);
|
||||
this.emails = new EmailManager(this);
|
||||
this.radius = new RadiusManager(this);
|
||||
this.workHosters = new WorkHosterManager(this);
|
||||
}
|
||||
|
||||
// =====================
|
||||
|
||||
@@ -9,12 +9,14 @@ export class RemoteIngress {
|
||||
public name: string;
|
||||
public secret: string;
|
||||
public listenPorts: number[];
|
||||
public listenPortsUdp?: number[];
|
||||
public enabled: boolean;
|
||||
public autoDerivePorts: boolean;
|
||||
public tags?: string[];
|
||||
public createdAt: number;
|
||||
public updatedAt: number;
|
||||
public effectiveListenPorts?: number[];
|
||||
public effectiveListenPortsUdp?: number[];
|
||||
public manualPorts?: number[];
|
||||
public derivedPorts?: number[];
|
||||
|
||||
@@ -24,12 +26,14 @@ export class RemoteIngress {
|
||||
this.name = data.name;
|
||||
this.secret = data.secret;
|
||||
this.listenPorts = data.listenPorts;
|
||||
this.listenPortsUdp = data.listenPortsUdp;
|
||||
this.enabled = data.enabled;
|
||||
this.autoDerivePorts = data.autoDerivePorts;
|
||||
this.tags = data.tags;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
this.effectiveListenPorts = data.effectiveListenPorts;
|
||||
this.effectiveListenPortsUdp = data.effectiveListenPortsUdp;
|
||||
this.manualPorts = data.manualPorts;
|
||||
this.derivedPorts = data.derivedPorts;
|
||||
}
|
||||
@@ -52,11 +56,13 @@ export class RemoteIngress {
|
||||
const edge = response.edge;
|
||||
this.name = edge.name;
|
||||
this.listenPorts = edge.listenPorts;
|
||||
this.listenPortsUdp = edge.listenPortsUdp;
|
||||
this.enabled = edge.enabled;
|
||||
this.autoDerivePorts = edge.autoDerivePorts;
|
||||
this.tags = edge.tags;
|
||||
this.updatedAt = edge.updatedAt;
|
||||
this.effectiveListenPorts = edge.effectiveListenPorts;
|
||||
this.effectiveListenPortsUdp = edge.effectiveListenPortsUdp;
|
||||
this.manualPorts = edge.manualPorts;
|
||||
this.derivedPorts = edge.derivedPorts;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ export class Route {
|
||||
|
||||
// Data from IMergedRoute
|
||||
public routeConfig: IRouteConfig;
|
||||
public source: 'hardcoded' | 'programmatic';
|
||||
public id: string;
|
||||
public enabled: boolean;
|
||||
public overridden: boolean;
|
||||
public storedRouteId?: string;
|
||||
public origin: 'config' | 'email' | 'dns' | 'api';
|
||||
public createdAt?: number;
|
||||
public updatedAt?: number;
|
||||
|
||||
@@ -22,21 +21,17 @@ export class Route {
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
|
||||
this.clientRef = clientRef;
|
||||
this.routeConfig = data.route;
|
||||
this.source = data.source;
|
||||
this.id = data.id;
|
||||
this.enabled = data.enabled;
|
||||
this.overridden = data.overridden;
|
||||
this.storedRouteId = data.storedRouteId;
|
||||
this.origin = data.origin;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
public async update(changes: Partial<IRouteConfig>): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
|
||||
'updateRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to update route');
|
||||
@@ -44,12 +39,9 @@ export class Route {
|
||||
}
|
||||
|
||||
public async delete(): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
|
||||
'deleteRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete route');
|
||||
@@ -57,41 +49,15 @@ export class Route {
|
||||
}
|
||||
|
||||
public async toggle(enabled: boolean): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to toggle route');
|
||||
}
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public async setOverride(enabled: boolean): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to set route override');
|
||||
}
|
||||
this.overridden = true;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public async removeOverride(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to remove route override');
|
||||
}
|
||||
this.overridden = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteBuilder {
|
||||
@@ -144,9 +110,8 @@ export class RouteBuilder {
|
||||
}
|
||||
|
||||
// Return a Route instance by re-fetching the list
|
||||
// The created route is programmatic, so we find it by storedRouteId
|
||||
const { routes } = await new RouteManager(this.clientRef).list();
|
||||
const created = routes.find((r) => r.storedRouteId === response.storedRouteId);
|
||||
const created = routes.find((r) => r.id === response.routeId);
|
||||
if (created) {
|
||||
return created;
|
||||
}
|
||||
@@ -154,10 +119,9 @@ export class RouteBuilder {
|
||||
// Fallback: construct from known data
|
||||
return new Route(this.clientRef, {
|
||||
route: this.routeConfig as IRouteConfig,
|
||||
source: 'programmatic',
|
||||
id: response.routeId || '',
|
||||
enabled: this.isEnabled,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
origin: 'api',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -190,10 +154,9 @@ export class RouteManager {
|
||||
}
|
||||
return new Route(this.clientRef, {
|
||||
route: routeConfig,
|
||||
source: 'programmatic',
|
||||
id: response.routeId || '',
|
||||
enabled: enabled ?? true,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
origin: 'api',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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 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';
|
||||
|
||||
+90
-208
@@ -1,20 +1,18 @@
|
||||
# @serve.zone/dcrouter-apiclient
|
||||
|
||||
A typed, object-oriented API client for DcRouter with a fluent builder pattern. 🔧
|
||||
|
||||
Programmatically manage your DcRouter instance — routes, certificates, API tokens, remote ingress edges, RADIUS, email operations, and more — all with full TypeScript type safety and an intuitive OO interface.
|
||||
`@serve.zone/dcrouter-apiclient` is the object-oriented TypeScript client for the dcrouter OpsServer API. It wraps `/typedrequest` calls in managers, builders, and resource classes for routes, certificates, API tokens, remote ingress, email, stats, config, logs, RADIUS, and WorkHoster integrations.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
```
|
||||
|
||||
Or import directly from the main package:
|
||||
The same client is also exposed as a subpath of the main package:
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
@@ -23,239 +21,123 @@ import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter-apiclient';
|
||||
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
|
||||
|
||||
// Authenticate
|
||||
await client.login('admin', 'password');
|
||||
|
||||
// List routes
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
console.log(`${routes.length} routes, ${warnings.length} warnings`);
|
||||
|
||||
// Check health
|
||||
const { health } = await client.stats.getHealth();
|
||||
console.log(`Healthy: ${health.healthy}`);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 🔐 Authentication
|
||||
|
||||
```typescript
|
||||
// Login with credentials — identity is stored and auto-injected into all subsequent requests
|
||||
const identity = await client.login('admin', 'password');
|
||||
|
||||
// Verify current session
|
||||
const { valid } = await client.verifyIdentity();
|
||||
|
||||
// Logout
|
||||
await client.logout();
|
||||
|
||||
// Or use an API token for programmatic access (route management only)
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
apiToken: 'dcr_your_token_here',
|
||||
});
|
||||
|
||||
await client.login('admin', 'admin');
|
||||
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
console.log(routes.length, warnings.length);
|
||||
|
||||
const route = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
|
||||
.save();
|
||||
|
||||
await route.toggle(true);
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The client supports session login and API-token authentication.
|
||||
|
||||
```typescript
|
||||
const sessionClient = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
await sessionClient.login('admin', 'admin');
|
||||
|
||||
const tokenClient = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
apiToken: 'dcr_token_value',
|
||||
});
|
||||
```
|
||||
|
||||
### 🌐 Routes — OO Resources + Builder
|
||||
`baseUrl` is normalized by removing trailing slashes. Requests are sent to `${baseUrl}/typedrequest`. `buildRequestPayload()` injects the current identity and optional API token for manager methods.
|
||||
|
||||
Routes are returned as `Route` instances with methods for update, delete, toggle, and overrides:
|
||||
## Manager Map
|
||||
|
||||
| Manager | Purpose |
|
||||
| --- | --- |
|
||||
| `client.routes` | List merged routes, build API routes, update/delete API routes, and toggle routes. |
|
||||
| `client.certificates` | Inspect certificate summaries and trigger certificate operations. |
|
||||
| `client.apiTokens` | Create, list, toggle, roll, and revoke API tokens. |
|
||||
| `client.remoteIngress` | Manage edge registrations, statuses, ports, tags, and connection tokens. |
|
||||
| `client.emails` | Inspect received/cached email items and trigger resend flows. |
|
||||
| `client.workHosters` | Manage WorkHoster-facing route/application integration calls. |
|
||||
| `client.stats` | Read health, counters, summaries, and runtime status. |
|
||||
| `client.config` | Read the current configuration view. |
|
||||
| `client.logs` | Read recent log information. |
|
||||
| `client.radius` | Manage RADIUS clients, VLAN mappings, and accounting sessions. |
|
||||
|
||||
## Route Builder
|
||||
|
||||
```typescript
|
||||
// List all routes (hardcoded + programmatic)
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
|
||||
// Inspect a route
|
||||
const route = routes[0];
|
||||
console.log(route.name, route.source, route.enabled);
|
||||
|
||||
// Modify a programmatic route
|
||||
await route.update({ name: 'renamed-route' });
|
||||
await route.toggle(false);
|
||||
await route.delete();
|
||||
|
||||
// Override a hardcoded route (disable it)
|
||||
const hardcodedRoute = routes.find(r => r.source === 'hardcoded');
|
||||
await hardcodedRoute.setOverride(false);
|
||||
await hardcodedRoute.removeOverride();
|
||||
```
|
||||
|
||||
**Builder pattern** for creating new routes:
|
||||
|
||||
```typescript
|
||||
const newRoute = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
||||
.setTls({ mode: 'terminate', certificate: 'auto' })
|
||||
const route = await client.routes.build()
|
||||
.setName('internal-app')
|
||||
.setMatch({
|
||||
ports: 443,
|
||||
domains: ['internal.example.com'],
|
||||
})
|
||||
.setAction({
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 3000 }],
|
||||
})
|
||||
.setEnabled(true)
|
||||
.save();
|
||||
|
||||
// Or use quick creation
|
||||
const route = await client.routes.create(routeConfig);
|
||||
```
|
||||
|
||||
### 🔑 API Tokens
|
||||
|
||||
```typescript
|
||||
// List existing tokens
|
||||
const tokens = await client.apiTokens.list();
|
||||
|
||||
// Create with builder
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('ci-pipeline')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.addScope('config:read')
|
||||
.setExpiresInDays(90)
|
||||
.save();
|
||||
|
||||
console.log(token.tokenValue); // Only available at creation time!
|
||||
|
||||
// Manage tokens
|
||||
await token.toggle(false); // Disable
|
||||
const newValue = await token.roll(); // Regenerate secret
|
||||
await token.revoke(); // Delete
|
||||
```
|
||||
|
||||
### 🔐 Certificates
|
||||
|
||||
```typescript
|
||||
const { certificates, summary } = await client.certificates.list();
|
||||
console.log(`${summary.valid} valid, ${summary.expiring} expiring, ${summary.failed} failed`);
|
||||
|
||||
// Operate on individual certificates
|
||||
const cert = certificates[0];
|
||||
await cert.reprovision();
|
||||
const exported = await cert.export();
|
||||
await cert.delete();
|
||||
|
||||
// Import a certificate
|
||||
await client.certificates.import({
|
||||
id: 'cert-id',
|
||||
domainName: 'example.com',
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 3600 * 1000,
|
||||
privateKey: '...',
|
||||
publicKey: '...',
|
||||
csr: '...',
|
||||
await route.update({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 3001 }],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 🌍 Remote Ingress
|
||||
System routes from `config`, `email`, and `dns` origins are designed to be toggled, not edited. Full create/update/delete behavior is for routes with origin `api`.
|
||||
|
||||
## API Tokens and Remote Ingress
|
||||
|
||||
```typescript
|
||||
// List edges and their statuses
|
||||
const edges = await client.remoteIngress.list();
|
||||
const statuses = await client.remoteIngress.getStatuses();
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('automation')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.setExpiresInDays(30)
|
||||
.save();
|
||||
|
||||
// Create with builder
|
||||
const edge = await client.remoteIngress.build()
|
||||
.setName('edge-nyc-01')
|
||||
.setName('edge-eu-1')
|
||||
.setListenPorts([80, 443])
|
||||
.setAutoDerivePorts(true)
|
||||
.setTags(['us-east'])
|
||||
.setTags(['production', 'eu'])
|
||||
.save();
|
||||
|
||||
// Manage an edge
|
||||
await edge.update({ name: 'edge-nyc-02' });
|
||||
const newSecret = await edge.regenerateSecret();
|
||||
const token = await edge.getConnectionToken();
|
||||
await edge.delete();
|
||||
const connectionToken = await edge.getConnectionToken();
|
||||
console.log(token.tokenValue, connectionToken);
|
||||
```
|
||||
|
||||
### 📊 Statistics (Read-Only)
|
||||
## What This Package Is Not
|
||||
|
||||
```typescript
|
||||
const serverStats = await client.stats.getServer({ timeRange: '24h', includeHistory: true });
|
||||
const emailStats = await client.stats.getEmail({ domain: 'example.com' });
|
||||
const dnsStats = await client.stats.getDns();
|
||||
const security = await client.stats.getSecurity({ includeDetails: true });
|
||||
const connections = await client.stats.getConnections({ protocol: 'https' });
|
||||
const queues = await client.stats.getQueues();
|
||||
const health = await client.stats.getHealth(true);
|
||||
const network = await client.stats.getNetwork();
|
||||
const combined = await client.stats.getCombined({ server: true, email: true });
|
||||
```
|
||||
- It does not start dcrouter.
|
||||
- It does not serve or bundle the Ops dashboard.
|
||||
- It does not replace `@serve.zone/dcrouter-interfaces` when you want raw TypedRequest contracts.
|
||||
|
||||
### ⚙️ Configuration & Logs
|
||||
Use `@serve.zone/dcrouter` for the server runtime and `@serve.zone/dcrouter-interfaces` for shared request/data types.
|
||||
|
||||
```typescript
|
||||
// Read-only configuration
|
||||
const config = await client.config.get();
|
||||
const emailSection = await client.config.get('email');
|
||||
## Development
|
||||
|
||||
// Logs
|
||||
const { logs, total, hasMore } = await client.logs.getRecent({
|
||||
level: 'error',
|
||||
category: 'smtp',
|
||||
limit: 50,
|
||||
});
|
||||
```
|
||||
This folder is published from the dcrouter monorepo via `tspublish.json` with order `5`.
|
||||
|
||||
### 📧 Email Operations
|
||||
Useful source entry points:
|
||||
|
||||
```typescript
|
||||
const emails = await client.emails.list();
|
||||
const email = emails[0];
|
||||
const detail = await email.getDetail();
|
||||
await email.resend();
|
||||
|
||||
// Or use the manager directly
|
||||
const detail2 = await client.emails.getDetail('email-id');
|
||||
await client.emails.resend('email-id');
|
||||
```
|
||||
|
||||
### 📡 RADIUS
|
||||
|
||||
```typescript
|
||||
// Client management
|
||||
const clients = await client.radius.clients.list();
|
||||
await client.radius.clients.set({
|
||||
name: 'switch-1',
|
||||
ipRange: '192.168.1.0/24',
|
||||
secret: 'shared-secret',
|
||||
enabled: true,
|
||||
});
|
||||
await client.radius.clients.remove('switch-1');
|
||||
|
||||
// VLAN management
|
||||
const { mappings, config: vlanConfig } = await client.radius.vlans.list();
|
||||
await client.radius.vlans.set({ mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true });
|
||||
const result = await client.radius.vlans.testAssignment('aa:bb:cc:dd:ee:ff');
|
||||
await client.radius.vlans.updateConfig({ defaultVlan: 200 });
|
||||
|
||||
// Sessions
|
||||
const { sessions } = await client.radius.sessions.list({ vlanId: 10 });
|
||||
await client.radius.sessions.disconnect('session-id', 'Admin disconnect');
|
||||
|
||||
// Statistics & Accounting
|
||||
const stats = await client.radius.getStatistics();
|
||||
const summary = await client.radius.getAccountingSummary(startTime, endTime);
|
||||
```
|
||||
|
||||
## API Surface
|
||||
|
||||
| Manager | Methods |
|
||||
|---------|---------|
|
||||
| `client.login()` / `logout()` / `verifyIdentity()` | Authentication |
|
||||
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
|
||||
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
|
||||
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
|
||||
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
|
||||
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getRateLimits()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
|
||||
| `client.config` | `get(section?)` |
|
||||
| `client.logs` | `getRecent()`, `getStream()` |
|
||||
| `client.emails` | `list()`, `getDetail()`, `resend()` → Email: `getDetail()`, `resend()` |
|
||||
| `client.radius` | `.clients.list/set/remove()`, `.vlans.list/set/remove/updateConfig/testAssignment()`, `.sessions.list/disconnect()`, `getStatistics()`, `getAccountingSummary()` |
|
||||
|
||||
## Architecture
|
||||
|
||||
The client uses HTTP-based [TypedRequest](https://code.foss.global/api.global/typedrequest) for transport. All requests are sent as POST to `{baseUrl}/typedrequest`. Authentication (JWT identity and/or API token) is automatically injected into every request payload via `buildRequestPayload()`.
|
||||
|
||||
Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) hold a reference to the client and provide instance methods that fire the appropriate TypedRequest operations. Builder classes (`RouteBuilder`, `ApiTokenBuilder`, `RemoteIngressBuilder`) use fluent chaining and a terminal `.save()` method.
|
||||
- `index.ts` exports the public client surface.
|
||||
- `classes.dcrouterapiclient.ts` owns authentication and request dispatch.
|
||||
- `classes.route.ts` owns route resources and builders.
|
||||
- `classes.remoteingress.ts`, `classes.apitoken.ts`, `classes.radius.ts`, and the other manager files wrap focused API domains.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -271,7 +153,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* DNS record validation status for a single email-related record (MX, SPF, DKIM, DMARC).
|
||||
*/
|
||||
export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked';
|
||||
|
||||
/**
|
||||
* An email domain managed by dcrouter.
|
||||
*
|
||||
* Each email domain is linked to an existing dcrouter DNS domain (dcrouter-hosted
|
||||
* or provider-managed). The DNS management path is inherited from the linked domain
|
||||
* — no separate DNS mode is needed.
|
||||
*/
|
||||
export interface IEmailDomain {
|
||||
id: string;
|
||||
/** Fully qualified email domain name (e.g. 'example.com' or 'mail.example.com'). */
|
||||
domain: string;
|
||||
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
|
||||
linkedDomainId: string;
|
||||
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Empty/undefined = bare domain. */
|
||||
subdomain?: string;
|
||||
/** DKIM configuration and key state. */
|
||||
dkim: IEmailDomainDkim;
|
||||
/** Optional per-domain rate limits. */
|
||||
rateLimits?: IEmailDomainRateLimits;
|
||||
/** DNS record validation status — populated by validateDns(). */
|
||||
dnsStatus: IEmailDomainDnsStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IEmailDomainDkim {
|
||||
/** DKIM selector (default: 'default'). */
|
||||
selector: string;
|
||||
/** RSA key size in bits (default: 2048). */
|
||||
keySize: number;
|
||||
/** Base64-encoded public key — populated after key generation. */
|
||||
publicKey?: string;
|
||||
/** Whether automatic key rotation is enabled. */
|
||||
rotateKeys: boolean;
|
||||
/** Days between key rotations (default: 90). */
|
||||
rotationIntervalDays: number;
|
||||
/** ISO date of last key rotation. */
|
||||
lastRotatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IEmailDomainRateLimits {
|
||||
outbound?: {
|
||||
messagesPerMinute?: number;
|
||||
messagesPerHour?: number;
|
||||
messagesPerDay?: number;
|
||||
};
|
||||
inbound?: {
|
||||
messagesPerMinute?: number;
|
||||
connectionsPerIp?: number;
|
||||
recipientsPerMessage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IEmailDomainDnsStatus {
|
||||
mx: TDnsRecordStatus;
|
||||
spf: TDnsRecordStatus;
|
||||
dkim: TDnsRecordStatus;
|
||||
dmarc: TDnsRecordStatus;
|
||||
lastCheckedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single required DNS record for an email domain — used for display / copy-paste.
|
||||
*/
|
||||
export interface IEmailDnsRecord {
|
||||
type: 'MX' | 'TXT';
|
||||
name: string;
|
||||
value: string;
|
||||
status: TDnsRecordStatus;
|
||||
}
|
||||
@@ -6,5 +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 './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -36,6 +36,64 @@ export interface IRemoteIngressStatus {
|
||||
activeTunnels: number;
|
||||
lastHeartbeat: number | null;
|
||||
connectedAt: number | null;
|
||||
transportMode?: 'tcpTls' | 'quic' | 'quicWithFallback';
|
||||
fallbackUsed?: boolean;
|
||||
performance?: IRemoteIngressPerformanceEffective;
|
||||
flowControl?: IRemoteIngressFlowControlStatus;
|
||||
queues?: IRemoteIngressQueueStatus;
|
||||
traffic?: IRemoteIngressTrafficStatus;
|
||||
udp?: IRemoteIngressUdpStatus;
|
||||
}
|
||||
|
||||
export type TRemoteIngressPerformanceProfile = 'balanced' | 'throughput' | 'highConcurrency';
|
||||
|
||||
export interface IRemoteIngressPerformanceConfig {
|
||||
profile?: TRemoteIngressPerformanceProfile;
|
||||
maxStreamsPerEdge?: number;
|
||||
totalWindowBudgetBytes?: number;
|
||||
minStreamWindowBytes?: number;
|
||||
maxStreamWindowBytes?: number;
|
||||
sustainedStreamWindowBytes?: number;
|
||||
quicDatagramReceiveBufferBytes?: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressPerformanceEffective {
|
||||
profile: TRemoteIngressPerformanceProfile;
|
||||
maxStreamsPerEdge: number;
|
||||
totalWindowBudgetBytes: number;
|
||||
minStreamWindowBytes: number;
|
||||
maxStreamWindowBytes: number;
|
||||
sustainedStreamWindowBytes: number;
|
||||
quicDatagramReceiveBufferBytes: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressFlowControlStatus {
|
||||
applies: boolean;
|
||||
currentWindowBytes: number;
|
||||
minWindowBytes: number;
|
||||
maxWindowBytes: number;
|
||||
totalWindowBudgetBytes: number;
|
||||
estimatedInFlightBytes: number;
|
||||
stalledStreams: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressQueueStatus {
|
||||
ctrlQueueDepth: number;
|
||||
dataQueueDepth: number;
|
||||
sustainedQueueDepth: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressTrafficStatus {
|
||||
bytesIn: number;
|
||||
bytesOut: number;
|
||||
streamsOpenedTotal: number;
|
||||
streamsClosedTotal: number;
|
||||
rejectedStreams: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressUdpStatus {
|
||||
activeSessions: number;
|
||||
droppedDatagrams: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,10 @@ export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||
// ============================================================================
|
||||
|
||||
export type TApiTokenScope =
|
||||
| '*'
|
||||
| 'routes:read' | 'routes:write'
|
||||
| 'config:read'
|
||||
| 'certificates:read' | 'certificates:write'
|
||||
| 'tokens:read' | 'tokens:manage'
|
||||
| 'source-profiles:read' | 'source-profiles:write'
|
||||
| 'target-profiles:read' | 'target-profiles:write'
|
||||
@@ -18,7 +20,35 @@ export type TApiTokenScope =
|
||||
| 'dns-providers:read' | 'dns-providers:write'
|
||||
| 'domains:read' | 'domains:write'
|
||||
| 'dns-records:read' | 'dns-records:write'
|
||||
| 'acme-config:read' | 'acme-config:write';
|
||||
| 'acme-config:read' | 'acme-config:write'
|
||||
| 'email-domains:read' | 'email-domains:write'
|
||||
| 'gateway-clients:read' | 'gateway-clients:write'
|
||||
| 'workhosters:read' | 'workhosters:write';
|
||||
|
||||
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,27 +110,39 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A merged route combining hardcoded and programmatic sources.
|
||||
* A route entry returned by the route management API.
|
||||
*/
|
||||
export interface IMergedRoute {
|
||||
route: IDcRouterRouteConfig;
|
||||
source: 'hardcoded' | 'programmatic';
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
overridden: boolean;
|
||||
storedRouteId?: string;
|
||||
origin: 'config' | 'email' | 'dns' | 'api';
|
||||
systemKey?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
metadata?: IRouteMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning generated during route merge/startup.
|
||||
* A warning generated during route startup/apply.
|
||||
*/
|
||||
export interface IRouteWarning {
|
||||
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
|
||||
type: 'disabled-route';
|
||||
routeName: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -112,6 +154,7 @@ export interface IApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
policy?: IApiTokenPolicy;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
@@ -123,28 +166,20 @@ export interface IApiTokenInfo {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A programmatic route stored in /config-api/routes/{id}.json
|
||||
* A route persisted in the database.
|
||||
*/
|
||||
export interface IStoredRoute {
|
||||
export interface IRoute {
|
||||
id: string;
|
||||
route: IDcRouterRouteConfig;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
origin: 'config' | 'email' | 'dns' | 'api';
|
||||
systemKey?: string;
|
||||
metadata?: IRouteMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
|
||||
*/
|
||||
export interface IRouteOverride {
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored API token, stored in /config-api/tokens/{id}.json
|
||||
*/
|
||||
@@ -153,6 +188,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;
|
||||
}
|
||||
@@ -119,6 +119,8 @@ export interface IConnectionInfo {
|
||||
state: 'connecting' | 'connected' | 'authenticated' | 'transmitting' | 'closing';
|
||||
bytesReceived: number;
|
||||
bytesSent: number;
|
||||
/** Present when the row is an aggregate, e.g. one row per remote IP. */
|
||||
connectionCount?: number;
|
||||
}
|
||||
|
||||
export interface IQueueStatus {
|
||||
@@ -143,6 +145,20 @@ export interface IHealthStatus {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface IDomainActivity {
|
||||
domain: string;
|
||||
bytesInPerSecond: number;
|
||||
bytesOutPerSecond: number;
|
||||
activeConnections: number;
|
||||
routeCount: number;
|
||||
/** Lifetime request count when available from SmartProxy. */
|
||||
requestCount: number;
|
||||
/** Live HTTP request rate when SmartProxy exposes per-domain rates. */
|
||||
requestsPerSecond?: number;
|
||||
/** HTTP requests over the last minute when SmartProxy exposes per-domain rates. */
|
||||
requestsLastMinute?: number;
|
||||
}
|
||||
|
||||
export interface INetworkMetrics {
|
||||
totalBandwidth: {
|
||||
in: number;
|
||||
@@ -156,12 +172,21 @@ export interface INetworkMetrics {
|
||||
connectionDetails: IConnectionDetails[];
|
||||
topEndpoints: Array<{
|
||||
endpoint: string;
|
||||
requests: number;
|
||||
connections: number;
|
||||
bandwidth: {
|
||||
in: number;
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
topEndpointsByBandwidth: Array<{
|
||||
endpoint: string;
|
||||
connections: number;
|
||||
bandwidth: {
|
||||
in: number;
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
domainActivity: IDomainActivity[];
|
||||
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
|
||||
requestsPerSecond?: number;
|
||||
requestsTotal?: number;
|
||||
@@ -190,9 +215,12 @@ export interface IConnectionDetails {
|
||||
startTime: number;
|
||||
bytesIn: number;
|
||||
bytesOut: number;
|
||||
/** Present when the row is an aggregate, e.g. one row per remote IP. */
|
||||
connectionCount?: number;
|
||||
}
|
||||
|
||||
export interface IBackendInfo {
|
||||
id?: string;
|
||||
backend: string;
|
||||
domain: string | null;
|
||||
protocol: string;
|
||||
@@ -232,4 +260,4 @@ export interface IVpnStats {
|
||||
registeredClients: number;
|
||||
connectedClients: number;
|
||||
wgListenPort: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ITargetProfile {
|
||||
domains?: string[];
|
||||
/** Specific IP:port targets this profile grants access to */
|
||||
targets?: ITargetProfileTarget[];
|
||||
/** Route references by stored route ID or route name */
|
||||
/** Route references by stored route ID. Legacy route names are normalized when unique. */
|
||||
routeRefs?: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { IDomain } from './domain.js';
|
||||
import type { IDnsRecord, TDnsRecordType } from './dns-record.js';
|
||||
import type { 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 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;
|
||||
}
|
||||
+51
-296
@@ -1,333 +1,88 @@
|
||||
# @serve.zone/dcrouter-interfaces
|
||||
|
||||
TypeScript interfaces and type definitions for the DcRouter OpsServer API. 📡
|
||||
|
||||
This module provides strongly-typed interfaces for communicating with the DcRouter OpsServer via [TypedRequest](https://code.foss.global/api.global/typedrequest). Use these interfaces for type-safe API interactions in your frontend applications or integration code.
|
||||
`@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
|
||||
```
|
||||
|
||||
Or import directly from the main package:
|
||||
The same contracts are exposed through the main package subpath:
|
||||
|
||||
```typescript
|
||||
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
```
|
||||
|
||||
## Usage
|
||||
## What It Exports
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `data` | Shared runtime-shaped models such as identities, routes, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
|
||||
| `requests` | TypedRequest request/response contracts for OpsServer methods. |
|
||||
| `typedrequestInterfaces` | Helper types re-exported from `@api.global/typedrequest-interfaces` through `plugins.ts`. |
|
||||
|
||||
## Covered API Areas
|
||||
|
||||
| Area | Examples |
|
||||
| --- | --- |
|
||||
| Auth | admin login, 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, combined stats, logs, configuration |
|
||||
| WorkHoster | external app/workhoster route ownership contracts |
|
||||
|
||||
## Raw TypedRequest Example
|
||||
|
||||
```typescript
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||
|
||||
// Use data interfaces for type definitions
|
||||
const identity: data.IIdentity = {
|
||||
jwt: 'your-jwt-token',
|
||||
userId: 'user-123',
|
||||
name: 'Admin User',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
role: 'admin'
|
||||
jwt: 'jwt-token',
|
||||
userId: 'admin-1',
|
||||
name: 'Admin',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
role: 'admin',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
// Use request interfaces for API calls
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
const statsClient = new typedrequest.TypedRequest<requests.IReq_GetServerStatistics>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getServerStatistics'
|
||||
const request = new TypedRequest<requests.IReq_GetMergedRoutes>(
|
||||
'https://dcrouter.example.com/typedrequest',
|
||||
'getMergedRoutes',
|
||||
);
|
||||
|
||||
const stats = await statsClient.fire({
|
||||
identity,
|
||||
includeHistory: true,
|
||||
timeRange: '24h'
|
||||
});
|
||||
```
|
||||
const response = await request.fire({ identity });
|
||||
|
||||
## Module Structure
|
||||
|
||||
### Data Interfaces (`data`)
|
||||
|
||||
Core data types used throughout the DcRouter system:
|
||||
|
||||
#### `IIdentity`
|
||||
Authentication identity for API requests:
|
||||
```typescript
|
||||
interface IIdentity {
|
||||
jwt: string; // JWT token
|
||||
userId: string; // Unique user ID
|
||||
name: string; // Display name
|
||||
expiresAt: number; // Token expiration timestamp
|
||||
role?: string; // User role (e.g., 'admin')
|
||||
type?: string; // Identity type
|
||||
for (const route of response.routes) {
|
||||
console.log(route.id, route.origin, route.enabled, route.systemKey);
|
||||
}
|
||||
```
|
||||
|
||||
#### Statistics Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `IServerStats` | Uptime, memory, CPU, connection counts |
|
||||
| `IEmailStats` | Sent/received/bounced/queued/failed, delivery & bounce rates |
|
||||
| `IDnsStats` | Total queries, cache hits/misses, query types |
|
||||
| `IRateLimitInfo` | Domain rate limit status (current rate, limit, remaining) |
|
||||
| `ISecurityMetrics` | Blocked IPs, spam/malware/phishing counts |
|
||||
| `IConnectionInfo` | Connection ID, remote address, protocol, state, bytes |
|
||||
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
||||
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||
| `IRadiusStats` | Running, uptime, auth requests/accepts/rejects, sessions, data transfer |
|
||||
| `IVpnStats` | Running, subnet, registered/connected clients, WireGuard port |
|
||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||
## When To Use It
|
||||
|
||||
#### Route Management Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `IMergedRoute` | Combined route: routeConfig, source (hardcoded/programmatic), enabled, overridden |
|
||||
| `IRouteWarning` | Merge warning: disabled-hardcoded, disabled-programmatic, orphaned-override |
|
||||
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
||||
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
||||
- 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.
|
||||
|
||||
#### Security & Reference Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `ISecurityProfile` | Reusable security config: id, name, description, security (ipAllowList, ipBlockList, maxConnections, rateLimit, etc.), extendsProfiles |
|
||||
| `INetworkTarget` | Reusable host:port destination: id, name, description, host (string or string[]), port |
|
||||
| `IRouteMetadata` | Route-to-reference links: securityProfileRef, networkTargetRef, snapshot names, lastResolvedAt |
|
||||
## Development
|
||||
|
||||
#### Remote Ingress Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
||||
| `IRouteVpn` | Route-level VPN config: `enabled`/`mandatory` flags and optional `allowedServerDefinedClientTags` |
|
||||
This folder is published from the dcrouter monorepo via `tspublish.json` with order `1`, before the client package that imports it.
|
||||
|
||||
#### VPN Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
|
||||
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
|
||||
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
||||
Useful source entry points:
|
||||
|
||||
### Request Interfaces (`requests`)
|
||||
|
||||
TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||
|
||||
#### 🔐 Authentication
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_AdminLoginWithUsernameAndPassword` | `adminLoginWithUsernameAndPassword` | Authenticate as admin |
|
||||
| `IReq_AdminLogout` | `adminLogout` | End admin session |
|
||||
| `IReq_VerifyIdentity` | `verifyIdentity` | Verify JWT token validity |
|
||||
|
||||
#### 📊 Statistics
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetServerStatistics` | `getServerStatistics` | Overall server stats |
|
||||
| `IReq_GetEmailStatistics` | `getEmailStatistics` | Email throughput metrics |
|
||||
| `IReq_GetDnsStatistics` | `getDnsStatistics` | DNS query stats |
|
||||
| `IReq_GetRateLimitStatus` | `getRateLimitStatus` | Rate limit status |
|
||||
| `IReq_GetSecurityMetrics` | `getSecurityMetrics` | Security event metrics |
|
||||
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
||||
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
||||
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
||||
| `IReq_GetNetworkStats` | `getNetworkStats` | Network throughput and connection analytics |
|
||||
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request (server, email, DNS, security, network, RADIUS, VPN) |
|
||||
|
||||
#### ⚙️ Configuration
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetConfiguration` | `getConfiguration` | Current config (read-only) |
|
||||
|
||||
#### 📜 Logs
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetRecentLogs` | `getLogs` | Retrieve system logs |
|
||||
| `IReq_GetLogStream` | `getLogStream` | Stream live logs |
|
||||
|
||||
#### 📧 Email Operations
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetAllEmails` | `getAllEmails` | List all emails |
|
||||
| `IReq_GetEmailDetail` | `getEmailDetail` | Full detail for a specific email |
|
||||
| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email |
|
||||
|
||||
#### 🛣️ Route Management
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetMergedRoutes` | `getMergedRoutes` | List all routes (hardcoded + programmatic) |
|
||||
| `IReq_CreateRoute` | `createRoute` | Create a new programmatic route |
|
||||
| `IReq_UpdateRoute` | `updateRoute` | Update a programmatic route |
|
||||
| `IReq_DeleteRoute` | `deleteRoute` | Delete a programmatic route |
|
||||
| `IReq_ToggleRoute` | `toggleRoute` | Enable/disable a programmatic route |
|
||||
| `IReq_SetRouteOverride` | `setRouteOverride` | Override a hardcoded route |
|
||||
| `IReq_RemoveRouteOverride` | `removeRouteOverride` | Remove a route override |
|
||||
|
||||
#### 🔑 API Token Management
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_CreateApiToken` | `createApiToken` | Create a new API token |
|
||||
| `IReq_ListApiTokens` | `listApiTokens` | List all tokens |
|
||||
| `IReq_RevokeApiToken` | `revokeApiToken` | Revoke (delete) a token |
|
||||
| `IReq_RollApiToken` | `rollApiToken` | Regenerate token secret |
|
||||
| `IReq_ToggleApiToken` | `toggleApiToken` | Enable/disable a token |
|
||||
|
||||
#### 🔐 Certificates
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetCertificateOverview` | `getCertificateOverview` | Domain-centric certificate status |
|
||||
| `IReq_ReprovisionCertificate` | `reprovisionCertificate` | Reprovision by route name (legacy) |
|
||||
| `IReq_ReprovisionCertificateDomain` | `reprovisionCertificateDomain` | Reprovision by domain (preferred) |
|
||||
| `IReq_ImportCertificate` | `importCertificate` | Import a certificate |
|
||||
| `IReq_ExportCertificate` | `exportCertificate` | Export a certificate |
|
||||
| `IReq_DeleteCertificate` | `deleteCertificate` | Delete a certificate |
|
||||
|
||||
#### Certificate Types
|
||||
```typescript
|
||||
type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
|
||||
type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
|
||||
|
||||
interface ICertificateInfo {
|
||||
domain: string;
|
||||
routeNames: string[];
|
||||
status: TCertificateStatus;
|
||||
source: TCertificateSource;
|
||||
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||
expiryDate?: string;
|
||||
issuer?: string;
|
||||
issuedAt?: string;
|
||||
error?: string;
|
||||
canReprovision: boolean;
|
||||
backoffInfo?: {
|
||||
failures: number;
|
||||
retryAfter?: string;
|
||||
lastError?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 🌍 Remote Ingress
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_CreateRemoteIngress` | `createRemoteIngress` | Register a new edge node |
|
||||
| `IReq_DeleteRemoteIngress` | `deleteRemoteIngress` | Remove an edge registration |
|
||||
| `IReq_UpdateRemoteIngress` | `updateRemoteIngress` | Update edge settings |
|
||||
| `IReq_RegenerateRemoteIngressSecret` | `regenerateRemoteIngressSecret` | Issue a new secret |
|
||||
| `IReq_GetRemoteIngresses` | `getRemoteIngresses` | List all edge registrations |
|
||||
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
||||
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
|
||||
|
||||
#### 🔐 VPN
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
|
||||
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
|
||||
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
|
||||
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
|
||||
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
|
||||
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
|
||||
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
|
||||
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
|
||||
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
|
||||
|
||||
#### 📡 RADIUS
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetRadiusClients` | `getRadiusClients` | List NAS clients |
|
||||
| `IReq_SetRadiusClient` | `setRadiusClient` | Add/update a NAS client |
|
||||
| `IReq_RemoveRadiusClient` | `removeRadiusClient` | Remove a NAS client |
|
||||
| `IReq_GetVlanMappings` | `getVlanMappings` | List VLAN mappings |
|
||||
| `IReq_SetVlanMapping` | `setVlanMapping` | Add/update VLAN mapping |
|
||||
| `IReq_RemoveVlanMapping` | `removeVlanMapping` | Remove VLAN mapping |
|
||||
| `IReq_TestVlanAssignment` | `testVlanAssignment` | Test what VLAN a MAC gets |
|
||||
| `IReq_GetRadiusSessions` | `getRadiusSessions` | List active sessions |
|
||||
| `IReq_DisconnectRadiusSession` | `disconnectRadiusSession` | Force disconnect |
|
||||
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
|
||||
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
|
||||
|
||||
#### 🛡️ Security Profiles
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetSecurityProfiles` | `getSecurityProfiles` | List all security profiles |
|
||||
| `IReq_GetSecurityProfile` | `getSecurityProfile` | Get a single profile by ID |
|
||||
| `IReq_CreateSecurityProfile` | `createSecurityProfile` | Create a reusable security profile |
|
||||
| `IReq_UpdateSecurityProfile` | `updateSecurityProfile` | Update a profile (propagates to routes) |
|
||||
| `IReq_DeleteSecurityProfile` | `deleteSecurityProfile` | Delete a profile (with optional force) |
|
||||
| `IReq_GetSecurityProfileUsage` | `getSecurityProfileUsage` | Get routes referencing a profile |
|
||||
|
||||
#### 🎯 Network Targets
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetNetworkTargets` | `getNetworkTargets` | List all network targets |
|
||||
| `IReq_GetNetworkTarget` | `getNetworkTarget` | Get a single target by ID |
|
||||
| `IReq_CreateNetworkTarget` | `createNetworkTarget` | Create a reusable host:port target |
|
||||
| `IReq_UpdateNetworkTarget` | `updateNetworkTarget` | Update a target (propagates to routes) |
|
||||
| `IReq_DeleteNetworkTarget` | `deleteNetworkTarget` | Delete a target (with optional force) |
|
||||
| `IReq_GetNetworkTargetUsage` | `getNetworkTargetUsage` | Get routes referencing a target |
|
||||
|
||||
## Example: Full API Integration
|
||||
|
||||
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
||||
|
||||
```typescript
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||
|
||||
// 1. Login
|
||||
const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const loginResponse = await loginClient.fire({
|
||||
username: 'admin',
|
||||
password: 'your-password'
|
||||
});
|
||||
const identity = loginResponse.identity;
|
||||
|
||||
// 2. Fetch combined metrics
|
||||
const metricsClient = new typedrequest.TypedRequest<requests.IReq_GetCombinedMetrics>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getCombinedMetrics'
|
||||
);
|
||||
|
||||
const metrics = await metricsClient.fire({ identity });
|
||||
console.log('Server:', metrics.metrics.server);
|
||||
console.log('Email:', metrics.metrics.email);
|
||||
|
||||
// 3. Check certificate status
|
||||
const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOverview>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getCertificateOverview'
|
||||
);
|
||||
|
||||
const certs = await certClient.fire({ identity });
|
||||
console.log(`Certificates: ${certs.summary.valid} valid, ${certs.summary.failed} failed`);
|
||||
|
||||
// 4. List remote ingress edges
|
||||
const edgesClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngresses>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getRemoteIngresses'
|
||||
);
|
||||
|
||||
const edges = await edgesClient.fire({ identity });
|
||||
console.log('Registered edges:', edges.edges.length);
|
||||
|
||||
// 5. Generate a connection token for an edge
|
||||
const tokenClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngressConnectionToken>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getRemoteIngressConnectionToken'
|
||||
);
|
||||
|
||||
const tokenResponse = await tokenClient.fire({ identity, edgeId: edges.edges[0].id });
|
||||
console.log('Connection token:', tokenResponse.token);
|
||||
```
|
||||
- `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
|
||||
|
||||
@@ -343,7 +98,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
@@ -1,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
|
||||
@@ -19,6 +19,7 @@ export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.impl
|
||||
identity: authInterfaces.IIdentity;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
policy?: IApiTokenPolicy;
|
||||
expiresInDays?: number | null;
|
||||
};
|
||||
response: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface IConfigData {
|
||||
hubDomain: string | null;
|
||||
tlsMode: 'custom' | 'acme' | 'self-signed';
|
||||
connectedEdgeIps: string[];
|
||||
performance?: import('../data/remoteingress.js').IRemoteIngressPerformanceConfig;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -148,3 +148,31 @@ export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implemen
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a domain between dcrouter-hosted and provider-managed (or between providers).
|
||||
* Records are transferred to the target and the domain source/providerId are updated.
|
||||
*/
|
||||
export interface IReq_MigrateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_MigrateDomain
|
||||
> {
|
||||
method: 'migrateDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
/** Target source type. */
|
||||
targetSource: import('../data/domain.js').TDomainSource;
|
||||
/** Required when targetSource is 'provider'. */
|
||||
targetProviderId?: string;
|
||||
/** When migrating to a provider: delete all existing records at the provider first. */
|
||||
deleteExistingProviderRecords?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
/** Number of records migrated. */
|
||||
recordsMigrated?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IEmailDomain, IEmailDnsRecord } from '../data/email-domain.js';
|
||||
|
||||
// ============================================================================
|
||||
// Email Domain Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* List all email domains.
|
||||
*/
|
||||
export interface IReq_GetEmailDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomains
|
||||
> {
|
||||
method: 'getEmailDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
domains: IEmailDomain[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single email domain by id.
|
||||
*/
|
||||
export interface IReq_GetEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomain
|
||||
> {
|
||||
method: 'getEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
domain: IEmailDomain | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an email domain. Links to an existing dcrouter DNS domain.
|
||||
* Generates DKIM keys and computes the required DNS records.
|
||||
*/
|
||||
export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateEmailDomain
|
||||
> {
|
||||
method: 'createEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
/** ID of the existing dcrouter DNS domain to link to. */
|
||||
linkedDomainId: string;
|
||||
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Leave empty for bare domain. */
|
||||
subdomain?: string;
|
||||
/** DKIM selector (default: 'default'). */
|
||||
dkimSelector?: string;
|
||||
/** RSA key size (default: 2048). */
|
||||
dkimKeySize?: number;
|
||||
/** Enable automatic key rotation (default: false). */
|
||||
rotateKeys?: boolean;
|
||||
/** Days between rotations (default: 90). */
|
||||
rotationIntervalDays?: number;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
domain?: IEmailDomain;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an email domain's configuration.
|
||||
*/
|
||||
export interface IReq_UpdateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateEmailDomain
|
||||
> {
|
||||
method: 'updateEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
rateLimits?: IEmailDomain['rateLimits'];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an email domain.
|
||||
*/
|
||||
export interface IReq_DeleteEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteEmailDomain
|
||||
> {
|
||||
method: 'deleteEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger DNS validation for an email domain.
|
||||
* Performs live lookups for MX, SPF, DKIM, and DMARC records.
|
||||
*/
|
||||
export interface IReq_ValidateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ValidateEmailDomain
|
||||
> {
|
||||
method: 'validateEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
domain?: IEmailDomain;
|
||||
records?: IEmailDnsRecord[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required DNS records for an email domain (for display / copy-paste).
|
||||
*/
|
||||
export interface IReq_GetEmailDomainDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomainDnsRecords
|
||||
> {
|
||||
method: 'getEmailDomainDnsRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
records: IEmailDnsRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-provision DNS records for an email domain.
|
||||
* Creates any missing MX, SPF, DKIM, and DMARC records via the linked
|
||||
* domain's DNS path (dcrouter zone or provider API).
|
||||
*/
|
||||
export interface IReq_ProvisionEmailDomainDns extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ProvisionEmailDomainDns
|
||||
> {
|
||||
method: 'provisionEmailDomainDns';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
/** Number of records created. */
|
||||
provisioned?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -17,4 +17,7 @@ export * from './users.js';
|
||||
export * from './dns-providers.js';
|
||||
export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domains.js';
|
||||
export * from './workhoster.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all merged routes (hardcoded + programmatic) with warnings.
|
||||
* Get all routes with warnings.
|
||||
*/
|
||||
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -27,7 +27,7 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new programmatic route.
|
||||
* Create a new route.
|
||||
*/
|
||||
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
storedRouteId?: string;
|
||||
routeId?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a programmatic route.
|
||||
* Update a route.
|
||||
*/
|
||||
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -71,7 +71,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a programmatic route.
|
||||
* Delete a route.
|
||||
*/
|
||||
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -90,46 +90,7 @@ export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.impleme
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an override on a hardcoded route (disable/enable by name).
|
||||
*/
|
||||
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SetRouteOverride
|
||||
> {
|
||||
method: 'setRouteOverride';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an override from a hardcoded route (restore default behavior).
|
||||
*/
|
||||
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveRouteOverride
|
||||
> {
|
||||
method: 'removeRouteOverride';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a programmatic route on/off by id.
|
||||
* Toggle a route on/off by id.
|
||||
*/
|
||||
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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;
|
||||
};
|
||||
response: {
|
||||
rules: ISecurityBlockRule[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateSecurityBlockRule
|
||||
> {
|
||||
method: 'createSecurityBlockRule';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
};
|
||||
response: {
|
||||
records: IIpIntelligenceRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetCompiledSecurityPolicy extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetCompiledSecurityPolicy
|
||||
> {
|
||||
method: 'getCompiledSecurityPolicy';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
policy: ISecurityCompiledPolicy;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ListSecurityPolicyAudit extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListSecurityPolicyAudit
|
||||
> {
|
||||
method: 'listSecurityPolicyAudit';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
events: ISecurityPolicyAuditEvent[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_RefreshIpIntelligence extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RefreshIpIntelligence
|
||||
> {
|
||||
method: 'refreshIpIntelligence';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
ipAddress: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
record?: IIpIntelligenceRecord;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -180,5 +180,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
|
||||
requestsPerSecond: number;
|
||||
requestsTotal: number;
|
||||
backends?: statsInterfaces.IBackendInfo[];
|
||||
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||
domainActivity: statsInterfaces.IDomainActivity[];
|
||||
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type {
|
||||
IGatewayClientDnsRecord,
|
||||
IGatewayClientDomain,
|
||||
IGatewayClientOwnership,
|
||||
IGatewayClientRouteSyncResult,
|
||||
IGatewayCapabilities,
|
||||
IWorkAppMailIdentity,
|
||||
IWorkAppMailIdentitySyncResult,
|
||||
IWorkAppMailInboundRoute,
|
||||
IWorkAppMailOwnership,
|
||||
IWorkAppRouteOwnership,
|
||||
IWorkAppRouteSyncResult,
|
||||
IWorkHosterDomain,
|
||||
} from '../data/workhoster.js';
|
||||
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
|
||||
|
||||
export interface IReq_GetGatewayCapabilities extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetGatewayCapabilities
|
||||
> {
|
||||
method: 'getGatewayCapabilities';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
capabilities: IGatewayCapabilities;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetWorkHosterDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetWorkHosterDomains
|
||||
> {
|
||||
method: 'getWorkHosterDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
domains: IWorkHosterDomain[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetGatewayClientDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetGatewayClientDomains
|
||||
> {
|
||||
method: 'getGatewayClientDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
gatewayClientId?: string;
|
||||
};
|
||||
response: {
|
||||
domains: IGatewayClientDomain[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetGatewayClientDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetGatewayClientDnsRecords
|
||||
> {
|
||||
method: 'getGatewayClientDnsRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
gatewayClientId?: string;
|
||||
};
|
||||
response: {
|
||||
records: IGatewayClientDnsRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SyncWorkAppRoute
|
||||
> {
|
||||
method: 'syncWorkAppRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
ownership: IWorkAppRouteOwnership;
|
||||
route?: IDcRouterRouteConfig;
|
||||
enabled?: boolean;
|
||||
delete?: boolean;
|
||||
};
|
||||
response: IWorkAppRouteSyncResult;
|
||||
}
|
||||
|
||||
export interface IReq_SyncGatewayClientRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SyncGatewayClientRoute
|
||||
> {
|
||||
method: 'syncGatewayClientRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
ownership: IGatewayClientOwnership;
|
||||
route?: IDcRouterRouteConfig;
|
||||
enabled?: boolean;
|
||||
delete?: boolean;
|
||||
};
|
||||
response: IGatewayClientRouteSyncResult;
|
||||
}
|
||||
|
||||
export interface IReq_GetWorkAppMailIdentities extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetWorkAppMailIdentities
|
||||
> {
|
||||
method: 'getWorkAppMailIdentities';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
ownership?: Partial<IWorkAppMailOwnership>;
|
||||
};
|
||||
response: {
|
||||
identities: IWorkAppMailIdentity[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_SyncWorkAppMailIdentity extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SyncWorkAppMailIdentity
|
||||
> {
|
||||
method: 'syncWorkAppMailIdentity';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
ownership: IWorkAppMailOwnership;
|
||||
localPart: string;
|
||||
domain: string;
|
||||
displayName?: string;
|
||||
inbound?: IWorkAppMailInboundRoute;
|
||||
enabled?: boolean;
|
||||
smtpEnabled?: boolean;
|
||||
resetSmtpPassword?: boolean;
|
||||
delete?: boolean;
|
||||
};
|
||||
response: IWorkAppMailIdentitySyncResult;
|
||||
}
|
||||
+92
-19
@@ -1,5 +1,3 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
/**
|
||||
* dcrouter migration runner.
|
||||
*
|
||||
@@ -21,6 +19,57 @@ export interface IMigrationRunner {
|
||||
run(): Promise<IMigrationRunResult>;
|
||||
}
|
||||
|
||||
async function migrateTargetProfileTargetHosts(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('TargetProfileDoc');
|
||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||
let migrated = 0;
|
||||
|
||||
for await (const doc of cursor) {
|
||||
const targets = ((doc as any).targets || []).map((target: any) => {
|
||||
if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) {
|
||||
const { host, ...rest } = target;
|
||||
return { ...rest, ip: host };
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
}
|
||||
|
||||
async function backfillSystemRouteKeys(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('RouteDoc');
|
||||
const cursor = collection.find({
|
||||
origin: { $in: ['config', 'email', 'dns'] },
|
||||
systemKey: { $exists: false },
|
||||
'route.name': { $type: 'string' },
|
||||
});
|
||||
let migrated = 0;
|
||||
|
||||
for await (const doc of cursor) {
|
||||
const origin = typeof (doc as any).origin === 'string' ? (doc as any).origin : undefined;
|
||||
const routeName = typeof (doc as any).route?.name === 'string' ? (doc as any).route.name.trim() : '';
|
||||
if (!origin || !routeName) continue;
|
||||
|
||||
await collection.updateOne(
|
||||
{ _id: (doc as any)._id },
|
||||
{ $set: { systemKey: `${origin}:${routeName}` } },
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -48,23 +97,7 @@ export async function createMigrationRunner(
|
||||
.step('rename-target-profile-host-to-ip')
|
||||
.from('13.0.11').to('13.1.0')
|
||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('targetprofiledoc');
|
||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||
let migrated = 0;
|
||||
for await (const doc of cursor) {
|
||||
const targets = ((doc as any).targets || []).map((t: any) => {
|
||||
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
|
||||
const { host, ...rest } = t;
|
||||
return { ...rest, ip: host };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||
migrated++;
|
||||
}
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
})
|
||||
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
|
||||
.step('rename-domain-source-manual-to-dcrouter')
|
||||
.from('13.1.0').to('13.8.1')
|
||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||
@@ -92,6 +125,46 @@ export async function createMigrationRunner(
|
||||
'info',
|
||||
`rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`,
|
||||
);
|
||||
})
|
||||
.step('unify-routes-rename-collection')
|
||||
.from('13.8.2').to('13.16.0')
|
||||
.description('Rename StoredRouteDoc → RouteDoc, add origin field, drop RouteOverrideDoc')
|
||||
.up(async (ctx) => {
|
||||
const db = ctx.mongo!;
|
||||
|
||||
// 1. Rename StoredRouteDoc → RouteDoc (smartdata uses exact class names)
|
||||
const collections = await db.listCollections({ name: 'StoredRouteDoc' }).toArray();
|
||||
if (collections.length > 0) {
|
||||
await db.renameCollection('StoredRouteDoc', 'RouteDoc');
|
||||
ctx.log.log('info', 'Renamed StoredRouteDoc → RouteDoc');
|
||||
}
|
||||
|
||||
// 2. Set origin='api' on all migrated docs (they were API-created)
|
||||
const routeCol = db.collection('RouteDoc');
|
||||
const result = await routeCol.updateMany(
|
||||
{ origin: { $exists: false } },
|
||||
{ $set: { origin: 'api' } },
|
||||
);
|
||||
ctx.log.log('info', `Set origin='api' on ${result.modifiedCount} migrated route(s)`);
|
||||
|
||||
// 3. Drop RouteOverrideDoc collection
|
||||
const overrideCollections = await db.listCollections({ name: 'RouteOverrideDoc' }).toArray();
|
||||
if (overrideCollections.length > 0) {
|
||||
await db.collection('RouteOverrideDoc').drop();
|
||||
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
|
||||
}
|
||||
})
|
||||
.step('repair-target-profile-ip-migration')
|
||||
.from('13.16.0').to('13.17.4')
|
||||
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
|
||||
.up(async (ctx) => {
|
||||
await migrateTargetProfileTargetHosts(ctx);
|
||||
})
|
||||
.step('backfill-system-route-keys')
|
||||
.from('13.17.4').to('13.18.0')
|
||||
.description('Backfill RouteDoc.systemKey for persisted config/email/dns routes')
|
||||
.up(async (ctx) => {
|
||||
await backfillSystemRouteKeys(ctx);
|
||||
});
|
||||
|
||||
return migration;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user