Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+25
-24
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.19.0",
|
||||
"version": "13.26.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -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.78.2",
|
||||
"@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/smartmigration": "1.3.1",
|
||||
"@push.rocks/smartmta": "^5.3.3",
|
||||
"@push.rocks/smartnetwork": "^4.6.0",
|
||||
"@push.rocks/smartnetwork": "^4.7.1",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.7.4",
|
||||
"@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.4",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@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.5",
|
||||
"lru-cache": "^11.3.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
|
||||
Generated
+1730
-1875
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,38 @@
|
||||
# @serve.zone/dcrouter
|
||||
|
||||

|
||||
|
||||
`dcrouter` is a TypeScript control plane for running a serious multi-protocol edge or datacenter gateway from one process. It orchestrates HTTP/HTTPS and TCP routing through SmartProxy, email through smartmta, authoritative DNS and DNS-over-HTTPS, RADIUS, remote ingress tunnels, VPN access control, a typed Ops API, and a web dashboard.
|
||||
|
||||
It is built for operators who want one place to define routes, expose services, manage certificates, register domains and DNS providers, control VPN-only access, and inspect what is going on in production.
|
||||
`dcrouter` is the serve.zone datacenter gateway runtime: a TypeScript control plane that brings HTTP/HTTPS/TCP routing, email ingress, authoritative DNS, RADIUS, VPN access control, remote ingress tunnels, certificate operations, metrics, and an Ops dashboard into one process.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Why dcrouter
|
||||
## Why It Exists
|
||||
|
||||
- 🌐 Run HTTP/HTTPS, TCP/SNI, email, DNS, RADIUS, VPN, and remote ingress from one orchestrated service.
|
||||
- 🔐 Keep certificates, routes, tokens, domains, and reusable route references in one management plane.
|
||||
- 🧠 Use system-managed routes for config-, email-, and DNS-derived traffic, plus API-managed routes for dynamic additions.
|
||||
- 📊 Get an Ops UI and TypedRequest API for monitoring, automation, and day-2 operations.
|
||||
- ⚡ Lean on Rust-backed data planes where it matters: proxying, DNS, email delivery, remote ingress, and VPN.
|
||||
Modern infrastructure often has too many tiny edge tools: a proxy here, a DNS daemon there, a separate cert worker, another dashboard, and a tunnel process bolted on later. `dcrouter` is designed as a cohesive gateway layer for operators who want one audited place to define public routes, domains, edge tunnels, access policy, and operational state.
|
||||
|
||||
## What It Covers
|
||||
Highlights:
|
||||
|
||||
| Area | What dcrouter does |
|
||||
- 🌐 SmartProxy-backed HTTP, HTTPS, TCP, TLS/SNI, and optional HTTP/3 route handling
|
||||
- 📬 SmartMTA-backed SMTP ingress and email-domain operations
|
||||
- 🧭 SmartDNS-backed authoritative DNS plus generated DNS-over-HTTPS routes
|
||||
- 🔐 ACME, certificate state, API tokens, users, source profiles, target profiles, and security policies
|
||||
- 🛡️ RADIUS, VLAN assignment, VPN-protected routes, and remote ingress firewall snapshots
|
||||
- 🖥️ Browser Ops dashboard and TypedRequest API served by the built-in OpsServer
|
||||
|
||||
## Runtime Areas
|
||||
|
||||
| Area | What dcrouter manages |
|
||||
| --- | --- |
|
||||
| HTTP / HTTPS / TCP | SmartProxy-based routing, TLS termination or passthrough, path/domain matching, optional HTTP/3 augmentation |
|
||||
| Email | smartmta-based SMTP ingress and delivery, route-based email handling, DKIM-aware domain support |
|
||||
| DNS | Authoritative DNS, DNS-over-HTTPS bootstrap routes, provider-backed and dcrouter-hosted domains and records |
|
||||
| Certificates | ACME-aware certificate management with dashboard and API support |
|
||||
| Access control | Source profiles, network targets, VPN-gated routes, API tokens, admin auth |
|
||||
| Network edge | Remote ingress hub for edge nodes tunneling traffic into the router |
|
||||
| Operations | Web dashboard, TypedRequest API, logs, metrics, health, route and token management |
|
||||
| Proxying | SmartProxy routes for HTTP, HTTPS, TCP, SNI, TLS termination, passthrough, and backend forwarding |
|
||||
| Route ownership | Constructor routes, generated email/DNS routes, and API-created routes with explicit origins |
|
||||
| DNS | Authoritative scopes, generated NS records, static DNS records, provider-backed domains, and DoH endpoints |
|
||||
| Email | UnifiedEmailServer startup, email-domain management, route-backed delivery actions, received mail operations |
|
||||
| Certificates | ACME config, stored certificate metadata, provisioning backoff, and certificate status reporting |
|
||||
| Edge access | Remote ingress hub, edge registrations, derived edge ports, pushed firewall rules, VPN-only route access |
|
||||
| Network auth | RADIUS clients, MAC Authentication Bypass, VLAN mapping, and accounting sessions |
|
||||
| Operations | Dashboard views, TypedRequest handlers, metrics, logs, health, API tokens, users, and configuration views |
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter
|
||||
@@ -38,7 +40,7 @@ pnpm add @serve.zone/dcrouter
|
||||
|
||||
## Quick Start
|
||||
|
||||
This is the smallest realistic setup: one HTTP route, embedded database enabled, and the Ops dashboard on port `3000`.
|
||||
This starts the gateway on unprivileged ports and stores data under the default `~/.serve.zone/dcrouter` base directory.
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
@@ -47,10 +49,10 @@ const router = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'app',
|
||||
name: 'local-app',
|
||||
match: {
|
||||
domains: ['app.example.com'],
|
||||
ports: [80],
|
||||
domains: ['localhost'],
|
||||
ports: [18080],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@@ -68,147 +70,93 @@ const router = new DcRouter({
|
||||
await router.start();
|
||||
```
|
||||
|
||||
Once the router is running, you can:
|
||||
After startup:
|
||||
|
||||
- open the Ops dashboard on `http://localhost:3000`
|
||||
- inspect the route in the System Routes view
|
||||
- add API-managed routes through the dashboard or API client
|
||||
- enable DNS, email, VPN, remote ingress, or RADIUS by adding the corresponding config blocks
|
||||
- open the dashboard at `http://localhost:3000`
|
||||
- log in with the current built-in development credentials `admin` / `admin`
|
||||
- send proxied traffic to `http://localhost:18080`
|
||||
- stop gracefully with `await router.stop()`
|
||||
|
||||
## Mental Model
|
||||
## Configuration Model
|
||||
|
||||
`dcrouter` is not a toy reverse proxy with a few side features. It is an orchestrator that wires multiple specialized services into one management plane.
|
||||
|
||||
| Layer | Responsibility |
|
||||
| --- | --- |
|
||||
| `DcRouter` | Startup order, shutdown, service wiring, configuration assembly, route hydration |
|
||||
| SmartProxy | HTTP/HTTPS, TCP/SNI, TLS, HTTP/3-capable route execution |
|
||||
| smartmta | SMTP ingress, queueing, DKIM-aware email processing and delivery |
|
||||
| SmartDNS | Authoritative DNS and DoH request handling |
|
||||
| smartradius | Network authentication, VLAN assignment, accounting |
|
||||
| remoteingress | Edge tunnel registrations and runtime forwarding into the hub |
|
||||
| smartvpn | VPN server and client access mediation for protected routes |
|
||||
| OpsServer + dashboard | Typed API and browser UI for operations |
|
||||
| smartdata-backed DB | Persistent routes, tokens, domains, records, profiles, cert metadata, caches |
|
||||
|
||||
## Route Model
|
||||
|
||||
Routes fall into two ownership classes:
|
||||
|
||||
| Route kind | Origin | Ownership | What users can do |
|
||||
| --- | --- | --- | --- |
|
||||
| System routes | `config`, `email`, `dns` | Derived from config or runtime-managed subsystems | View and toggle only |
|
||||
| API routes | `api` | Created through route-management API | Create, edit, delete, toggle |
|
||||
|
||||
Important details:
|
||||
|
||||
- system routes are persisted with a stable `systemKey`
|
||||
- config-, email-, and DNS-derived routes show up in the System Routes view
|
||||
- DoH routes are persisted as system-route templates and get their live socket handlers attached at apply time
|
||||
- system routes are managed by the system, not edited directly by operators
|
||||
|
||||
## Core Features
|
||||
|
||||
### Traffic Routing
|
||||
|
||||
- Domain-, port-, and path-based SmartProxy routes
|
||||
- HTTP/HTTPS reverse proxying and generic TCP/SNI forwarding
|
||||
- Optional HTTP/3 augmentation for qualifying HTTPS routes
|
||||
- Reusable source profiles and network targets for route composition
|
||||
- Remote ingress aware routing for edge-delivered traffic
|
||||
|
||||
### Email
|
||||
|
||||
- smartmta-based inbound email handling
|
||||
- Route-based mail actions such as forward, process, deliver, reject
|
||||
- DKIM-aware domain handling and DNS record generation support
|
||||
- Email-domain management through the Ops API and UI
|
||||
- Queue, resend, failure, and delivery inspection through the dashboard and API
|
||||
|
||||
### DNS
|
||||
|
||||
- Authoritative scopes via `dnsScopes`
|
||||
- Bootstrap nameserver domains via `dnsNsDomains`
|
||||
- DNS-over-HTTPS endpoints for `/dns-query` and `/resolve`
|
||||
- Managed domains, managed records, and provider-backed DNS integrations
|
||||
- Internal email DNS record generation for `internal-dns` email domains
|
||||
|
||||
### Certificates and ACME
|
||||
|
||||
- Certificate overview and operations through OpsServer
|
||||
- Import, export, delete, and reprovision flows
|
||||
- DB-backed ACME configuration management
|
||||
- Integration with managed DNS for certificate provisioning flows
|
||||
- Routes can declare `certificate: 'auto'`, but actual automated issuance depends on ACME being configured in the management plane
|
||||
|
||||
### VPN, RADIUS, and Remote Ingress
|
||||
|
||||
- VPN-gated routes with target-profile-based access matching
|
||||
- WireGuard-oriented VPN management with dcrouter-side client lifecycle support
|
||||
- RADIUS MAB, VLAN assignment, and accounting
|
||||
- Remote ingress hub for edge nodes tunneling traffic into central routes
|
||||
|
||||
### Operations Plane
|
||||
|
||||
- Web dashboard with overview, network, routes, access, security, domains, certificates, logs, and email views
|
||||
- TypedRequest API for automation and external control
|
||||
- API tokens with scoped access
|
||||
- Metrics, health, logs, and per-feature operational views
|
||||
|
||||
## Configuration Overview
|
||||
|
||||
The main entry point is `IDcRouterOptions`.
|
||||
`DcRouter` is configured with `IDcRouterOptions` from `@serve.zone/dcrouter`.
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `smartProxyConfig` | Main HTTP/HTTPS and TCP/SNI routing configuration |
|
||||
| `emailConfig` | smartmta server config and email routes |
|
||||
| `emailPortConfig` | External-to-internal email port mapping and email storage path tuning |
|
||||
| `dnsNsDomains` | Nameserver hostnames used for NS bootstrap and DoH routes |
|
||||
| `dnsScopes` | Authoritative DNS zones managed by dcrouter |
|
||||
| `dnsRecords` | Static constructor-defined records |
|
||||
| `publicIp` / `proxyIps` | DNS A-record exposure strategy |
|
||||
| `dbConfig` | Embedded or external Mongo-backed persistence and seeding |
|
||||
| `radiusConfig` | RADIUS authentication, VLAN, and accounting setup |
|
||||
| `remoteIngressConfig` | Edge tunnel hub setup |
|
||||
| `vpnConfig` | VPN server and client access configuration |
|
||||
| `http3` | Global HTTP/3 behavior for qualifying routes |
|
||||
| `opsServerPort` | Dashboard and TypedRequest API port |
|
||||
| `baseDir` | Root directory for dcrouter runtime data. Defaults to `~/.serve.zone/dcrouter`. |
|
||||
| `smartProxyConfig` | Main SmartProxy route configuration for HTTP/HTTPS/TCP/SNI traffic. |
|
||||
| `emailConfig` | UnifiedEmailServer configuration: hostname, ports, domains, and mail routes. |
|
||||
| `emailPortConfig` | External-to-internal email port mapping and received-email storage path. |
|
||||
| `tls` | Legacy/static TLS and ACME contact settings used to seed certificate config. |
|
||||
| `dnsNsDomains` | Nameserver hostnames used for generated NS records and DoH routes. |
|
||||
| `dnsScopes` | Authoritative domains served by the embedded DNS server. |
|
||||
| `dnsRecords` | Constructor-defined DNS records. |
|
||||
| `publicIp` / `proxyIps` | IPs used for generated A records and proxy-aware DNS exposure. |
|
||||
| `dbConfig` | Smartdata persistence via embedded LocalSmartDb or external MongoDB. |
|
||||
| `radiusConfig` | RADIUS authentication, accounting, and VLAN assignment. |
|
||||
| `remoteIngressConfig` | Remote ingress hub configuration for edge tunnel nodes. |
|
||||
| `vpnConfig` | VPN server/client definitions and VPN-only routing behavior. |
|
||||
| `http3` | HTTP/3 augmentation settings for qualifying HTTPS routes. |
|
||||
| `opsServerPort` | Port for the Ops dashboard and `/typedrequest` API. Defaults to `3000`. |
|
||||
|
||||
## Example: Enabling DNS, Email, and VPN
|
||||
Important runtime behavior:
|
||||
|
||||
- `dbConfig.enabled` defaults to enabled. Without `mongoDbUrl`, dcrouter uses embedded LocalSmartDb.
|
||||
- If the DB is disabled, constructor-defined proxy traffic can still run, but persistent API routes, tokens, managed domains, and stored certificate state are unavailable.
|
||||
- Qualifying HTTPS forward routes on port `443` are HTTP/3-augmented unless `http3.enabled === false` or the route opts out.
|
||||
- DNS-over-HTTPS routes are generated on the first `dnsNsDomains` entry at `/dns-query` and `/resolve`.
|
||||
- Email listener ports can be remapped internally, for example public `25`, `587`, and `465` to unprivileged internal ports.
|
||||
|
||||
## Route Ownership
|
||||
|
||||
dcrouter keeps generated and operator-created routes separate so automation can reconcile safely.
|
||||
|
||||
| Origin | Source | Mutability |
|
||||
| --- | --- | --- |
|
||||
| `config` | Constructor `smartProxyConfig.routes` and seed data | Toggle only |
|
||||
| `email` | Email listener and email-domain generated routes | Toggle only |
|
||||
| `dns` | Generated DNS-over-HTTPS and DNS-related routes | Toggle only |
|
||||
| `api` | Ops UI or API client | Full CRUD |
|
||||
|
||||
System routes are persisted with stable `systemKey` values. API-created routes are the editable route layer intended for operators and automation.
|
||||
|
||||
## Production-Flavored Example
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
baseDir: '/var/lib/dcrouter',
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-app',
|
||||
match: {
|
||||
domains: ['app.example.com'],
|
||||
ports: [443],
|
||||
},
|
||||
match: { domains: ['app.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
targets: [{ host: '10.10.0.21', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'internal-admin',
|
||||
match: { domains: ['admin.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.10.0.30', port: 9000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
vpnOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
emailConfig: {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587, 465],
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'internal-dns',
|
||||
},
|
||||
],
|
||||
domains: [{ domain: 'example.com', dnsMode: 'internal-dns' }],
|
||||
routes: [
|
||||
{
|
||||
name: 'inbound-mail',
|
||||
name: 'inbound-example',
|
||||
match: { recipients: '*@example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
@@ -220,102 +168,102 @@ const router = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
publicIp: '203.0.113.10',
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.example.com',
|
||||
clients: [
|
||||
{
|
||||
clientId: 'ops-laptop',
|
||||
description: 'Operations laptop',
|
||||
},
|
||||
],
|
||||
},
|
||||
dbConfig: {
|
||||
enabled: true,
|
||||
clients: [{ clientId: 'ops-laptop', description: 'Operations laptop' }],
|
||||
},
|
||||
opsServerPort: 3000,
|
||||
});
|
||||
|
||||
await router.start();
|
||||
```
|
||||
|
||||
## Operations API and Dashboard
|
||||
## Automation API
|
||||
|
||||
With the database enabled, dcrouter exposes a management plane for:
|
||||
|
||||
- routes and route toggles
|
||||
- API tokens
|
||||
- source profiles and network targets
|
||||
- DNS providers, domains, and records
|
||||
- ACME configuration and certificate lifecycle
|
||||
- email domains and email operations
|
||||
- VPN clients, remote ingress edges, and RADIUS data
|
||||
|
||||
The browser dashboard is built from the `ts_web` package and is served by OpsServer. The same backend is accessible programmatically via TypedRequest or the dedicated API client package.
|
||||
|
||||
## Programmatic API Client
|
||||
|
||||
Use the API client when you want automation or integration code instead of clicking through the dashboard.
|
||||
The OpsServer exposes TypedRequest handlers at `/typedrequest`. You can use raw contracts or the object-oriented API client.
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter-apiclient';
|
||||
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
|
||||
await client.login('admin', 'password');
|
||||
await client.login('admin', 'admin');
|
||||
|
||||
const { routes } = await client.routes.list();
|
||||
const systemRoutes = routes.filter((route) => route.origin !== 'api');
|
||||
|
||||
if (systemRoutes[0]) {
|
||||
await systemRoutes[0].toggle(false);
|
||||
}
|
||||
|
||||
await client.routes.build()
|
||||
const route = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8081 }] })
|
||||
.save();
|
||||
|
||||
await route.toggle(true);
|
||||
```
|
||||
|
||||
See `./ts_apiclient/readme.md` for the dedicated API-client package docs.
|
||||
Use `@serve.zone/dcrouter/interfaces` or `@serve.zone/dcrouter-interfaces` when you want the raw TypedRequest contracts instead of resource managers.
|
||||
|
||||
## OCI / Container Bootstrap
|
||||
|
||||
`runCli()` supports an environment-driven container mode when `DCROUTER_MODE=OCI_CONTAINER`.
|
||||
|
||||
```typescript
|
||||
import { runCli } from '@serve.zone/dcrouter';
|
||||
|
||||
await runCli();
|
||||
```
|
||||
|
||||
Supported environment overrides include:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `DCROUTER_CONFIG_PATH` | JSON file loaded as the base `IDcRouterOptions` object. |
|
||||
| `DCROUTER_BASE_DIR` | Runtime data root. |
|
||||
| `DCROUTER_TLS_EMAIL` / `DCROUTER_TLS_DOMAIN` | TLS/ACME seed settings. |
|
||||
| `DCROUTER_PUBLIC_IP` / `DCROUTER_PROXY_IPS` | Public/proxy IP exposure settings. |
|
||||
| `DCROUTER_DNS_NS_DOMAINS` / `DCROUTER_DNS_SCOPES` | DNS nameserver and authoritative scope settings. |
|
||||
| `DCROUTER_EMAIL_HOSTNAME` / `DCROUTER_EMAIL_PORTS` | Email server seed settings. |
|
||||
| `DCROUTER_CACHE_ENABLED` | Enables or disables DB-backed persistence. |
|
||||
| `DCROUTER_MAX_CONNECTIONS`, `DCROUTER_MAX_CONNECTIONS_PER_IP`, `DCROUTER_CONNECTION_RATE_LIMIT` | SmartProxy capacity and rate-limit overrides. |
|
||||
|
||||
## Published Modules
|
||||
|
||||
This repository publishes multiple modules from the same codebase.
|
||||
This repository intentionally publishes multiple module boundaries from one codebase.
|
||||
|
||||
| Module | Purpose | Docs |
|
||||
| --- | --- | --- |
|
||||
| `@serve.zone/dcrouter` | Main orchestrator and server package | `./readme.md` |
|
||||
| `@serve.zone/dcrouter-interfaces` | Shared TypedRequest request and data interfaces | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter-migrations` | Startup migration runner for dcrouter data | `./ts_migrations/readme.md` |
|
||||
| `@serve.zone/dcrouter-web` | Web dashboard entry and UI components | `./ts_web/readme.md` |
|
||||
| `@serve.zone/dcrouter-apiclient` | Typed OO API client | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter` | Main runtime and orchestrator | `./readme.md` |
|
||||
| `@serve.zone/dcrouter/interfaces` | Shared contracts as a subpath export | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter/apiclient` | API client as a subpath export | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-interfaces` | Standalone contracts package | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter-apiclient` | Standalone OO API client package | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-migrations` | Standalone migration runner package | `./ts_migrations/readme.md` |
|
||||
| `@serve.zone/dcrouter-web` | Dashboard frontend module boundary | `./ts_web/readme.md` |
|
||||
|
||||
## Development and Testing
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm test
|
||||
pnpm run watch
|
||||
```
|
||||
|
||||
Target a single test file while working on one area:
|
||||
Useful source entry points:
|
||||
|
||||
```bash
|
||||
tstest test/test.dns-runtime-routes.node.ts --verbose
|
||||
```
|
||||
|
||||
## Notes for Operators
|
||||
|
||||
- Database-backed management features depend on `dbConfig.enabled !== false`.
|
||||
- If you disable the DB, constructor-configured services still run, but persistent management features are limited.
|
||||
- Nameserver domains are still required for DNS bootstrap and DoH route generation.
|
||||
- HTTP/3 is enabled by default for qualifying HTTPS routes unless disabled globally or per route.
|
||||
- `ts/index.ts` exports `DcRouter`, `runCli()`, and public module surfaces.
|
||||
- `ts/classes.dcrouter.ts` owns service startup, dependency ordering, and `IDcRouterOptions`.
|
||||
- `ts/opsserver/classes.opsserver.ts` wires the dashboard server and TypedRequest handlers.
|
||||
- `ts/remoteingress/` integrates `@serve.zone/remoteingress` with stored edge registrations.
|
||||
- `ts_migrations/index.ts` contains all DB schema migration steps.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -331,7 +279,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,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();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { RouteConfigManager } from '../ts/config/index.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';
|
||||
@@ -204,6 +204,130 @@ tap.test('RouteConfigManager only allows toggling system routes', async () => {
|
||||
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();
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const TEST_PORT = 3201;
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||
const TEST_ADMIN_PASSWORD = 'test-admin-password';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
@@ -56,6 +57,7 @@ const queueItems = [
|
||||
];
|
||||
|
||||
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
|
||||
testDcRouter = new DcRouter({
|
||||
opsServerPort: TEST_PORT,
|
||||
dbConfig: { enabled: false },
|
||||
@@ -98,10 +100,16 @@ tap.test('should login as admin for email API tests', async () => {
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
password: TEST_ADMIN_PASSWORD,
|
||||
});
|
||||
|
||||
adminIdentity = response.identity;
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,6 +18,9 @@ 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 {
|
||||
@@ -45,6 +48,7 @@ function createProxyMetrics(args: {
|
||||
perSecond: () => 0,
|
||||
perMinute: () => 0,
|
||||
total: () => args.requestsTotal || 0,
|
||||
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
|
||||
},
|
||||
totals: {
|
||||
bytesIn: () => 0,
|
||||
@@ -52,10 +56,10 @@ function createProxyMetrics(args: {
|
||||
connections: () => 0,
|
||||
},
|
||||
backends: {
|
||||
byBackend: () => new Map<string, any>(),
|
||||
byBackend: () => args.backendMetrics || new Map<string, any>(),
|
||||
protocols: () => new Map<string, string>(),
|
||||
topByErrors: () => [],
|
||||
detectedProtocols: () => [],
|
||||
detectedProtocols: () => args.protocolCache || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -117,4 +121,122 @@ tap.test('MetricsManager joins domain activity to id-keyed route metrics', async
|
||||
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();
|
||||
@@ -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.19.0',
|
||||
version: '13.26.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+162
-23
@@ -26,12 +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, buildEmailDnsRecords } from './email/index.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
|
||||
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -177,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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -281,6 +285,8 @@ export class DcRouter {
|
||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||
public acmeConfigManager?: AcmeConfigManager;
|
||||
public emailDomainManager?: EmailDomainManager;
|
||||
public workAppMailManager: WorkAppMailManager;
|
||||
public securityPolicyManager?: SecurityPolicyManager;
|
||||
|
||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||
public detectedPublicIp: string | null = null;
|
||||
@@ -334,6 +340,7 @@ export class DcRouter {
|
||||
this.storageManager = new SmartMtaStorageManager(
|
||||
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
||||
);
|
||||
this.workAppMailManager = new WorkAppMailManager(this);
|
||||
|
||||
// Initialize service manager and register all services
|
||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||
@@ -468,12 +475,36 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence
|
||||
// and compiles the global block policy for SmartProxy and remote ingress edges.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SecurityPolicyManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.securityPolicyManager = new SecurityPolicyManager({
|
||||
onPolicyChanged: () => this.applySecurityPolicy(),
|
||||
});
|
||||
await this.securityPolicyManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.securityPolicyManager) {
|
||||
await this.securityPolicyManager.stop();
|
||||
this.securityPolicyManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||
const smartProxyDeps: string[] = [];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
smartProxyDeps.push('DcRouterDb');
|
||||
smartProxyDeps.push('DnsManager');
|
||||
smartProxyDeps.push('AcmeConfigManager');
|
||||
smartProxyDeps.push('SecurityPolicyManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartProxy')
|
||||
@@ -565,29 +596,20 @@ export class DcRouter {
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => 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(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
this.createVpnRouteAllowListResolver(),
|
||||
this.referenceResolver,
|
||||
// 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,
|
||||
@@ -977,6 +999,12 @@ export class DcRouter {
|
||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||
}
|
||||
|
||||
const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy();
|
||||
const mergedSecurityPolicy = this.mergeSecurityPolicies(
|
||||
(this.options.smartProxyConfig as any)?.securityPolicy,
|
||||
compiledSecurityPolicy,
|
||||
);
|
||||
|
||||
// If we have routes or need a basic SmartProxy instance, create it
|
||||
if (routes.length > 0 || this.options.smartProxyConfig) {
|
||||
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||
@@ -1008,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();
|
||||
@@ -1132,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
|
||||
@@ -1245,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
|
||||
@@ -1546,7 +1632,7 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// Create config with mapped ports
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
|
||||
...this.options.emailConfig,
|
||||
domains: transformedDomains,
|
||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
||||
@@ -1556,7 +1642,7 @@ export class DcRouter {
|
||||
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
||||
...this.options.emailConfig.queue,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Create unified email server
|
||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||
@@ -2233,6 +2319,9 @@ export class DcRouter {
|
||||
// Initialize the edge registration manager
|
||||
this.remoteIngressManager = new RemoteIngressManager();
|
||||
await this.remoteIngressManager.initialize();
|
||||
this.remoteIngressManager.setFirewallConfig(
|
||||
await this.securityPolicyManager?.compileRemoteIngressFirewall(),
|
||||
);
|
||||
|
||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||
@@ -2282,6 +2371,7 @@ export class DcRouter {
|
||||
tunnelPort: riCfg.tunnelPort ?? 8443,
|
||||
targetHost: '127.0.0.1',
|
||||
tls: tlsConfig,
|
||||
performance: riCfg.performance,
|
||||
});
|
||||
await this.tunnelManager.start();
|
||||
|
||||
@@ -2292,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;
|
||||
@@ -2441,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,6 +2,7 @@ import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { ApiTokenDoc } from '../db/index.js';
|
||||
import type {
|
||||
IApiTokenPolicy,
|
||||
IStoredApiToken,
|
||||
IApiTokenInfo,
|
||||
TApiTokenScope,
|
||||
@@ -33,6 +34,7 @@ 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);
|
||||
@@ -47,6 +49,7 @@ export class ApiTokenManager {
|
||||
name,
|
||||
tokenHash,
|
||||
scopes,
|
||||
policy,
|
||||
createdAt: now,
|
||||
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||
lastUsedAt: null,
|
||||
@@ -87,7 +90,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 +127,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,
|
||||
@@ -165,6 +193,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,
|
||||
@@ -181,6 +210,7 @@ export class ApiTokenManager {
|
||||
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 +223,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;
|
||||
|
||||
@@ -59,7 +59,7 @@ export class RouteConfigManager {
|
||||
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,
|
||||
) {}
|
||||
@@ -73,6 +73,12 @@ export class RouteConfigManager {
|
||||
return this.routes.get(id);
|
||||
}
|
||||
|
||||
public setVpnClientIpsResolver(
|
||||
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
): void {
|
||||
this.getVpnClientIpsForRoute = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted routes, seed serializable config/email/dns routes,
|
||||
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||
@@ -133,11 +139,11 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// 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: IRoute = {
|
||||
@@ -192,20 +198,32 @@ 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();
|
||||
@@ -238,6 +256,15 @@ export class RouteConfigManager {
|
||||
return this.updateRoute(id, { enabled });
|
||||
}
|
||||
|
||||
public findApiRouteByExternalKey(externalKey: string): IRoute | undefined {
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.origin === 'api' && route.metadata?.externalKey === externalKey) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: seed routes from constructor config
|
||||
// =========================================================================
|
||||
@@ -368,7 +395,7 @@ export class RouteConfigManager {
|
||||
createdBy: doc.createdBy,
|
||||
origin: doc.origin || 'api',
|
||||
systemKey: doc.systemKey,
|
||||
metadata: doc.metadata,
|
||||
metadata: this.normalizeRouteMetadata(doc.metadata),
|
||||
};
|
||||
|
||||
this.routes.set(doc.id, storedRoute);
|
||||
@@ -404,6 +431,74 @@ export class RouteConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// =========================================================================
|
||||
@@ -446,7 +541,7 @@ export class RouteConfigManager {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -482,7 +577,7 @@ export class RouteConfigManager {
|
||||
|
||||
// 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.routes.size} total)`);
|
||||
|
||||
@@ -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,78 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IIpIntelligenceRecord } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntelligenceDoc, IpIntelligenceDoc> implements IIpIntelligenceRecord {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public asnOrg: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public registrantOrg: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public registrantCountry: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public networkRange: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public networkCidrs: string[] | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public abuseContact: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public countryCode: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public city: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public latitude: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public longitude: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public accuracyRadius: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public timezone: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public firstSeenAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastSeenAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public seenCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByIp(ipAddress: string): Promise<IpIntelligenceDoc | null> {
|
||||
return await IpIntelligenceDoc.getInstance({ ipAddress });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<IpIntelligenceDoc[]> {
|
||||
return await IpIntelligenceDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityBlockRule, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityBlockRuleDoc extends plugins.smartdata.SmartDataDbDoc<SecurityBlockRuleDoc, SecurityBlockRuleDoc> implements ISecurityBlockRule {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TSecurityBlockRuleType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public matchMode?: TSecurityBlockRuleMatchMode;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public reason?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy: string = 'system';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SecurityBlockRuleDoc | null> {
|
||||
return await SecurityBlockRuleDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityPolicyAuditEvent } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityPolicyAuditDoc extends plugins.smartdata.SmartDataDbDoc<SecurityPolicyAuditDoc, SecurityPolicyAuditDoc> implements ISecurityPolicyAuditEvent {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public action!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public actor!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public details!: Record<string, unknown>;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findRecent(limit = 100): Promise<SecurityPolicyAuditDoc[]> {
|
||||
const docs = await SecurityPolicyAuditDoc.getInstances({});
|
||||
return docs.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// Cached/TTL document classes
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
export * from './classes.ip-intelligence.doc.js';
|
||||
export * from './classes.security-block-rule.doc.js';
|
||||
export * from './classes.security-policy-audit.doc.js';
|
||||
|
||||
// Config document classes
|
||||
export * from './classes.route.doc.js';
|
||||
|
||||
@@ -57,6 +57,31 @@ export class EmailDomainManager {
|
||||
return doc ? this.docToInterface(doc) : null;
|
||||
}
|
||||
|
||||
public async getByDomain(domainName: string): Promise<IEmailDomain | null> {
|
||||
const doc = await EmailDomainDoc.findByDomain(domainName);
|
||||
return doc ? this.docToInterface(doc) : null;
|
||||
}
|
||||
|
||||
public async ensureEmailDomainForDomainName(domainName: string): Promise<IEmailDomain | null> {
|
||||
const normalizedDomain = domainName.trim().toLowerCase();
|
||||
const existing = await this.getByDomain(normalizedDomain);
|
||||
if (existing) return existing;
|
||||
if (this.isDomainAlreadyConfigured(normalizedDomain)) return null;
|
||||
|
||||
const linkedDomain = await this.findLinkedDnsDomain(normalizedDomain);
|
||||
if (!linkedDomain) {
|
||||
throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
|
||||
}
|
||||
|
||||
const subdomain = normalizedDomain === linkedDomain.name
|
||||
? undefined
|
||||
: normalizedDomain.slice(0, -(linkedDomain.name.length + 1));
|
||||
return await this.createEmailDomain({
|
||||
linkedDomainId: linkedDomain.id,
|
||||
subdomain,
|
||||
});
|
||||
}
|
||||
|
||||
public async createEmailDomain(opts: {
|
||||
linkedDomainId: string;
|
||||
subdomain?: string;
|
||||
@@ -351,6 +376,13 @@ export class EmailDomainManager {
|
||||
return configuredDomains.includes(domainName.toLowerCase());
|
||||
}
|
||||
|
||||
private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
|
||||
const domains = await DomainDoc.findAll();
|
||||
return domains
|
||||
.filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
|
||||
.sort((a, b) => b.name.length - a.name.length)[0] || null;
|
||||
}
|
||||
|
||||
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
const managedConfigs: IEmailDomainConfig[] = [];
|
||||
@@ -378,7 +410,7 @@ export class EmailDomainManager {
|
||||
return managedConfigs;
|
||||
}
|
||||
|
||||
private async syncManagedDomainsToRuntime(): Promise<void> {
|
||||
public async syncManagedDomainsToRuntime(): Promise<void> {
|
||||
if (!this.dcRouter.options?.emailConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import type {
|
||||
IEmailRoute,
|
||||
IUnifiedEmailServerOptions,
|
||||
} from '@push.rocks/smartmta';
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as interfaces from '../../ts_interfaces/index.js';
|
||||
|
||||
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
|
||||
|
||||
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
|
||||
smtpPassword: string;
|
||||
}
|
||||
|
||||
interface IStoredWorkAppMailState {
|
||||
version: 1;
|
||||
identities: IStoredWorkAppMailIdentity[];
|
||||
}
|
||||
|
||||
export class WorkAppMailManager {
|
||||
private readonly storageKey = '/workhosters/mail-identities.json';
|
||||
|
||||
constructor(private dcRouterRef: any) {}
|
||||
|
||||
public async listMailIdentities(
|
||||
ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
|
||||
): Promise<interfaces.data.IWorkAppMailIdentity[]> {
|
||||
const identities = await this.readStoredIdentities();
|
||||
return identities
|
||||
.filter((identity) => this.matchesOwnership(identity.ownership, ownership))
|
||||
.map((identity) => this.toPublicIdentity(identity));
|
||||
}
|
||||
|
||||
public async syncMailIdentity(
|
||||
request: TSyncRequest,
|
||||
createdBy: string,
|
||||
): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
|
||||
if (!this.dcRouterRef.options.emailConfig) {
|
||||
return { success: false, message: 'Email server is not configured' };
|
||||
}
|
||||
|
||||
const ownership = this.normalizeOwnership(request.ownership);
|
||||
const domain = this.normalizeDomain(request.domain);
|
||||
const localPart = this.normalizeLocalPart(request.localPart);
|
||||
const address = `${localPart}@${domain}`;
|
||||
const externalKey = this.buildExternalKey(ownership, address);
|
||||
const identities = await this.readStoredIdentities();
|
||||
const existingIndex = identities.findIndex((identity) => identity.externalKey === externalKey);
|
||||
|
||||
if (request.delete) {
|
||||
if (existingIndex < 0) {
|
||||
return { success: true, action: 'unchanged' };
|
||||
}
|
||||
const [deletedIdentity] = identities.splice(existingIndex, 1);
|
||||
await this.writeStoredIdentities(identities);
|
||||
await this.applyStoredIdentitiesToRuntime(identities);
|
||||
return {
|
||||
success: true,
|
||||
action: 'deleted',
|
||||
identity: this.toPublicIdentity(deletedIdentity),
|
||||
};
|
||||
}
|
||||
|
||||
await this.ensureEmailDomainConfigured(domain);
|
||||
|
||||
const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
|
||||
const now = Date.now();
|
||||
const smtpPassword = existingIdentity && !request.resetSmtpPassword
|
||||
? existingIdentity.smtpPassword
|
||||
: this.generateSmtpPassword();
|
||||
const identity: IStoredWorkAppMailIdentity = {
|
||||
id: existingIdentity?.id || plugins.smartunique.shortId(),
|
||||
externalKey,
|
||||
ownership,
|
||||
address,
|
||||
localPart,
|
||||
domain,
|
||||
enabled: request.enabled ?? existingIdentity?.enabled ?? true,
|
||||
displayName: request.displayName ?? existingIdentity?.displayName,
|
||||
inbound: this.normalizeInboundRoute(request.inbound ?? existingIdentity?.inbound),
|
||||
smtp: {
|
||||
enabled: request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true,
|
||||
username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
|
||||
},
|
||||
createdAt: existingIdentity?.createdAt || now,
|
||||
updatedAt: now,
|
||||
createdBy: existingIdentity?.createdBy || createdBy,
|
||||
smtpPassword,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
identities[existingIndex] = identity;
|
||||
} else {
|
||||
identities.push(identity);
|
||||
}
|
||||
|
||||
await this.writeStoredIdentities(identities);
|
||||
await this.applyStoredIdentitiesToRuntime(identities);
|
||||
|
||||
const response: interfaces.data.IWorkAppMailIdentitySyncResult = {
|
||||
success: true,
|
||||
action: existingIndex >= 0 ? 'updated' : 'created',
|
||||
identity: this.toPublicIdentity(identity),
|
||||
};
|
||||
|
||||
if (existingIndex < 0 || request.resetSmtpPassword) {
|
||||
response.smtpCredentials = this.buildSmtpCredentials(identity);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
||||
emailConfig: TConfig,
|
||||
): Promise<TConfig> {
|
||||
const identities = await this.readStoredIdentities();
|
||||
return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities);
|
||||
}
|
||||
|
||||
public async applyStoredIdentitiesToRuntime(
|
||||
identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
|
||||
): Promise<void> {
|
||||
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
||||
if (!emailConfig) return;
|
||||
|
||||
const nextConfig = this.mergeIdentitiesIntoEmailConfig(
|
||||
emailConfig,
|
||||
identities || await this.readStoredIdentities(),
|
||||
);
|
||||
|
||||
this.dcRouterRef.options.emailConfig = nextConfig;
|
||||
if (this.dcRouterRef.emailServer) {
|
||||
this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
|
||||
await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
|
||||
}
|
||||
}
|
||||
|
||||
private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
|
||||
const storedData = await this.dcRouterRef.storageManager.get(this.storageKey);
|
||||
if (!storedData) return [];
|
||||
const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
|
||||
return Array.isArray(parsed) ? parsed : parsed.identities || [];
|
||||
}
|
||||
|
||||
private async writeStoredIdentities(identities: IStoredWorkAppMailIdentity[]): Promise<void> {
|
||||
const state: IStoredWorkAppMailState = {
|
||||
version: 1,
|
||||
identities,
|
||||
};
|
||||
await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
||||
emailConfig: TConfig,
|
||||
identities: IStoredWorkAppMailIdentity[],
|
||||
): TConfig {
|
||||
const generatedRoutes = identities
|
||||
.filter((identity) => identity.enabled && identity.inbound?.enabled)
|
||||
.map((identity) => this.buildInboundRoute(identity));
|
||||
const configuredRoutes = (emailConfig.routes || [])
|
||||
.filter((route) => !this.isManagedMailRouteName(route.name));
|
||||
const generatedUsers = identities
|
||||
.filter((identity) => identity.enabled && identity.smtp.enabled)
|
||||
.map((identity) => ({
|
||||
username: identity.smtp.username,
|
||||
password: identity.smtpPassword,
|
||||
}));
|
||||
const configuredUsers = (emailConfig.auth?.users || [])
|
||||
.filter((user) => !this.isManagedSmtpUsername(user.username));
|
||||
|
||||
return {
|
||||
...emailConfig,
|
||||
routes: [...configuredRoutes, ...generatedRoutes],
|
||||
auth: {
|
||||
...(emailConfig.auth || {}),
|
||||
users: [...configuredUsers, ...generatedUsers],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
|
||||
const inbound = identity.inbound!;
|
||||
return {
|
||||
name: this.buildRouteName(identity.externalKey),
|
||||
priority: 1000,
|
||||
match: {
|
||||
recipients: identity.address,
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: inbound.targetHost,
|
||||
port: inbound.targetPort,
|
||||
preserveHeaders: inbound.preserveHeaders ?? true,
|
||||
addHeaders: {
|
||||
'X-Dcrouter-WorkHoster-Type': identity.ownership.workHosterType,
|
||||
'X-Dcrouter-WorkHoster-Id': identity.ownership.workHosterId,
|
||||
'X-Dcrouter-WorkApp-Id': identity.ownership.workAppId,
|
||||
...(inbound.addHeaders || {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureEmailDomainConfigured(domain: string): Promise<void> {
|
||||
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
||||
if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailDomainManager = this.dcRouterRef.emailDomainManager;
|
||||
if (!emailDomainManager) {
|
||||
throw new Error(`Email domain is not configured: ${domain}`);
|
||||
}
|
||||
|
||||
if (await emailDomainManager.getByDomain(domain)) {
|
||||
await emailDomainManager.syncManagedDomainsToRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
await emailDomainManager.ensureEmailDomainForDomainName(domain);
|
||||
}
|
||||
|
||||
private normalizeOwnership(
|
||||
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||
): interfaces.data.IWorkAppMailOwnership {
|
||||
const workHosterType = ownership.workHosterType;
|
||||
const workHosterId = ownership.workHosterId?.trim();
|
||||
const workAppId = ownership.workAppId?.trim();
|
||||
if (!['onebox', 'cloudly', 'custom'].includes(workHosterType)) {
|
||||
throw new Error(`Invalid WorkHoster type: ${workHosterType}`);
|
||||
}
|
||||
if (!workHosterId) throw new Error('workHosterId is required');
|
||||
if (!workAppId) throw new Error('workAppId is required');
|
||||
return { workHosterType, workHosterId, workAppId };
|
||||
}
|
||||
|
||||
private normalizeDomain(domain: string): string {
|
||||
const normalized = domain?.trim().toLowerCase();
|
||||
if (!normalized || normalized.includes('@') || !normalized.includes('.')) {
|
||||
throw new Error(`Invalid email domain: ${domain}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeLocalPart(localPart: string): string {
|
||||
const normalized = localPart?.trim().toLowerCase();
|
||||
if (!normalized || normalized.includes('@') || /\s/.test(normalized)) {
|
||||
throw new Error(`Invalid email local part: ${localPart}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeInboundRoute(
|
||||
inbound?: interfaces.data.IWorkAppMailInboundRoute,
|
||||
): interfaces.data.IWorkAppMailInboundRoute | undefined {
|
||||
if (!inbound) return undefined;
|
||||
if (!inbound.enabled) {
|
||||
return { ...inbound, enabled: false };
|
||||
}
|
||||
const targetHost = inbound.targetHost?.trim();
|
||||
const targetPort = Number(inbound.targetPort);
|
||||
if (!targetHost) throw new Error('inbound.targetHost is required when inbound routing is enabled');
|
||||
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
|
||||
throw new Error(`Invalid inbound.targetPort: ${inbound.targetPort}`);
|
||||
}
|
||||
return {
|
||||
...inbound,
|
||||
targetHost,
|
||||
targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
private matchesOwnership(
|
||||
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||
filter?: Partial<interfaces.data.IWorkAppMailOwnership>,
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.workHosterType && filter.workHosterType !== ownership.workHosterType) return false;
|
||||
if (filter.workHosterId && filter.workHosterId !== ownership.workHosterId) return false;
|
||||
if (filter.workAppId && filter.workAppId !== ownership.workAppId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private buildExternalKey(
|
||||
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||
address: string,
|
||||
): string {
|
||||
return [
|
||||
ownership.workHosterType,
|
||||
ownership.workHosterId,
|
||||
ownership.workAppId,
|
||||
address,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
private buildSmtpUsername(externalKey: string): string {
|
||||
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
|
||||
}
|
||||
|
||||
private buildRouteName(externalKey: string): string {
|
||||
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
|
||||
}
|
||||
|
||||
private hashExternalKey(externalKey: string): string {
|
||||
return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
|
||||
}
|
||||
|
||||
private generateSmtpPassword(): string {
|
||||
return plugins.crypto.randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
private isManagedMailRouteName(routeName: string): boolean {
|
||||
return routeName.startsWith('workapp-mail-');
|
||||
}
|
||||
|
||||
private isManagedSmtpUsername(username: string): boolean {
|
||||
return username.startsWith('workapp-');
|
||||
}
|
||||
|
||||
private buildSmtpCredentials(
|
||||
identity: IStoredWorkAppMailIdentity,
|
||||
): interfaces.data.IWorkAppMailCredentials {
|
||||
return {
|
||||
username: identity.smtp.username,
|
||||
password: identity.smtpPassword,
|
||||
host: this.dcRouterRef.options.emailConfig?.outbound?.hostname
|
||||
|| this.dcRouterRef.options.emailConfig?.hostname,
|
||||
ports: {
|
||||
smtp: this.dcRouterRef.options.emailConfig?.ports?.includes(25) ? 25 : undefined,
|
||||
submission: this.dcRouterRef.options.emailConfig?.ports?.includes(587) ? 587 : undefined,
|
||||
smtps: this.dcRouterRef.options.emailConfig?.ports?.includes(465) ? 465 : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toPublicIdentity(
|
||||
identity: IStoredWorkAppMailIdentity,
|
||||
): interfaces.data.IWorkAppMailIdentity {
|
||||
const { smtpPassword, ...publicIdentity } = identity;
|
||||
return publicIdentity;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
export * from './classes.smartmta-storage-manager.js';
|
||||
export * from './classes.workapp-mail-manager.js';
|
||||
export * from './email-dns-records.js';
|
||||
|
||||
@@ -560,7 +560,9 @@ export class MetricsManager {
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [] as Array<any>,
|
||||
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>,
|
||||
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number; requestsPerSecond?: number; requestsLastMinute?: number }>,
|
||||
frontendProtocols: null,
|
||||
backendProtocols: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -592,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;
|
||||
@@ -619,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,
|
||||
@@ -678,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,
|
||||
@@ -720,6 +725,8 @@ export class MetricsManager {
|
||||
.slice(0, 10)
|
||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||
|
||||
void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
|
||||
|
||||
// Build domain activity using per-IP domain request counts from Rust engine
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const throughputByRoute = proxyMetrics.throughput.byRoute();
|
||||
@@ -750,6 +757,9 @@ export class MetricsManager {
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -775,11 +785,20 @@ export class MetricsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// For each route, compute the total request count across all its resolved domains
|
||||
// so we can distribute throughput/connections proportionally
|
||||
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 = domainRequestTotals.get(domain) || 0;
|
||||
const reqs = getDomainWeight(domain);
|
||||
for (const routeKey of routeKeys) {
|
||||
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
|
||||
}
|
||||
@@ -792,10 +811,13 @@ export class MetricsManager {
|
||||
bytesOutPerSec: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
requestsPerSecond: number;
|
||||
requestsLastMinute: number;
|
||||
}>();
|
||||
|
||||
for (const [domain, routeKeys] of domainToRoutes) {
|
||||
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||
const domainReqs = getDomainWeight(domain);
|
||||
const requestRate = domainRequestRates.get(domain);
|
||||
let totalConns = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
@@ -816,7 +838,9 @@ export class MetricsManager {
|
||||
bytesInPerSec: totalIn,
|
||||
bytesOutPerSec: totalOut,
|
||||
routeCount: routeKeys.length,
|
||||
requestCount: domainReqs,
|
||||
requestCount: domainRequestTotals.get(domain) || 0,
|
||||
requestsPerSecond: requestRate?.perSecond ?? 0,
|
||||
requestsLastMinute: requestRate?.lastMinute ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -828,8 +852,17 @@ export class MetricsManager {
|
||||
activeConnections: data.activeConnections,
|
||||
routeCount: data.routeCount,
|
||||
requestCount: data.requestCount,
|
||||
requestsPerSecond: data.requestsPerSecond,
|
||||
requestsLastMinute: data.requestsLastMinute,
|
||||
}))
|
||||
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
||||
.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,
|
||||
|
||||
@@ -38,6 +38,7 @@ export class OpsServer {
|
||||
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||
private emailDomainHandler!: handlers.EmailDomainHandler;
|
||||
private workHosterHandler!: handlers.WorkHosterHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -106,6 +107,7 @@ export class OpsServer {
|
||||
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
||||
this.workHosterHandler = new handlers.WorkHosterHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
@@ -119,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);
|
||||
}
|
||||
)
|
||||
|
||||
@@ -206,6 +206,7 @@ export class ConfigHandler {
|
||||
hubDomain: riCfg?.hubDomain || null,
|
||||
tlsMode,
|
||||
connectedEdgeIps,
|
||||
performance: riCfg?.performance,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,4 +18,5 @@ export * from './dns-provider.handler.js';
|
||||
export * from './domain.handler.js';
|
||||
export * from './dns-record.handler.js';
|
||||
export * from './acme-config.handler.js';
|
||||
export * from './email-domain.handler.js';
|
||||
export * from './email-domain.handler.js';
|
||||
export * from './workhoster.handler.js';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
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 }),
|
||||
};
|
||||
@@ -104,6 +106,8 @@ export class SecurityHandler {
|
||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||
requestsTotal: networkStats.requestsTotal || 0,
|
||||
backends: networkStats.backends || [],
|
||||
frontendProtocols: networkStats.frontendProtocols || null,
|
||||
backendProtocols: networkStats.backendProtocols || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,6 +124,8 @@ export class SecurityHandler {
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [],
|
||||
frontendProtocols: null,
|
||||
backendProtocols: null,
|
||||
};
|
||||
}
|
||||
)
|
||||
@@ -151,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<{
|
||||
@@ -335,4 +448,4 @@ export class SecurityHandler {
|
||||
limits: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ export class StatsHandler {
|
||||
startTime: 0,
|
||||
bytesIn: tp?.in || 0,
|
||||
bytesOut: tp?.out || 0,
|
||||
connectionCount: count,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -112,14 +112,11 @@ export class VpnManager {
|
||||
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.forwardingModeOverride ?? 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;
|
||||
@@ -218,7 +215,7 @@ export class VpnManager {
|
||||
throw new Error('VPN server not running');
|
||||
}
|
||||
|
||||
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
|
||||
await this.ensureForwardingModeForNextClient(opts.useHostIp === true);
|
||||
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = opts.clientId;
|
||||
@@ -298,6 +295,7 @@ export class VpnManager {
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
await this.reconcileForwardingMode();
|
||||
this.config.onClientChanged?.();
|
||||
}
|
||||
|
||||
@@ -368,8 +366,10 @@ export class VpnManager {
|
||||
await this.persistClient(client);
|
||||
|
||||
if (this.vpnServer) {
|
||||
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||
const restarted = await this.reconcileForwardingMode();
|
||||
if (!restarted) {
|
||||
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
@@ -563,6 +563,28 @@ export class VpnManager {
|
||||
?? '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,
|
||||
@@ -633,16 +655,45 @@ export class VpnManager {
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
|
||||
if (!useHostIp || !this.vpnServer) return;
|
||||
if (this.getResolvedForwardingMode() !== 'socket') return;
|
||||
|
||||
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
|
||||
this.forwardingModeOverride = 'hybrid';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
+72
-64
@@ -1,20 +1,18 @@
|
||||
# @serve.zone/dcrouter-apiclient
|
||||
|
||||
Typed, object-oriented API client for operating a running dcrouter instance. 🔧
|
||||
|
||||
Use this package when you want a clean TypeScript client instead of manually firing TypedRequest calls. It wraps the OpsServer API in resource managers and resource classes such as routes, certificates, tokens, edges, emails, stats, logs, config, and RADIUS.
|
||||
`@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 through the main package:
|
||||
The same client is also exposed as a subpath of the main package:
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
@@ -29,78 +27,60 @@ const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
|
||||
await client.login('admin', 'password');
|
||||
await client.login('admin', 'admin');
|
||||
|
||||
const { routes } = await client.routes.list();
|
||||
console.log(routes.map((route) => `${route.origin}:${route.name}`));
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
console.log(routes.length, warnings.length);
|
||||
|
||||
await client.routes.build()
|
||||
const route = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
|
||||
.save();
|
||||
|
||||
await route.toggle(true);
|
||||
```
|
||||
|
||||
## Authentication Modes
|
||||
## Authentication
|
||||
|
||||
| Mode | How it works |
|
||||
| --- | --- |
|
||||
| Admin login | Call `login(username, password)` and the client stores the returned identity for later requests |
|
||||
| API token | Pass `apiToken` into the constructor for token-based automation |
|
||||
The client supports session login and API-token authentication.
|
||||
|
||||
```typescript
|
||||
const client = new DcRouterApiClient({
|
||||
const sessionClient = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
apiToken: 'dcr_your_token_here',
|
||||
});
|
||||
await sessionClient.login('admin', 'admin');
|
||||
|
||||
const tokenClient = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
apiToken: 'dcr_token_value',
|
||||
});
|
||||
```
|
||||
|
||||
## Main Managers
|
||||
`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.
|
||||
|
||||
## Manager Map
|
||||
|
||||
| Manager | Purpose |
|
||||
| --- | --- |
|
||||
| `client.routes` | List routes and create API-managed routes |
|
||||
| `client.certificates` | Inspect and operate on certificate records |
|
||||
| `client.apiTokens` | Create, list, toggle, roll, revoke API tokens |
|
||||
| `client.remoteIngress` | Manage registered remote ingress edges |
|
||||
| `client.stats` | Read operational metrics and health data |
|
||||
| `client.config` | Read current configuration view |
|
||||
| `client.logs` | Read recent logs or stream them |
|
||||
| `client.emails` | List emails and trigger resend flows |
|
||||
| `client.radius` | Operate on RADIUS clients, VLANs, sessions, and accounting |
|
||||
| `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 Behavior
|
||||
|
||||
Routes are returned as `Route` instances with:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `enabled`
|
||||
- `origin`
|
||||
|
||||
Important behavior:
|
||||
|
||||
- API routes can be created, updated, deleted, and toggled.
|
||||
- System routes can be listed and toggled, but not edited or deleted.
|
||||
- A system route is any route whose `origin !== 'api'`.
|
||||
|
||||
```typescript
|
||||
const { routes } = await client.routes.list();
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.origin !== 'api') {
|
||||
await route.toggle(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Builder Example
|
||||
## Route Builder
|
||||
|
||||
```typescript
|
||||
const route = await client.routes.build()
|
||||
.setName('internal-app')
|
||||
.setMatch({
|
||||
ports: 80,
|
||||
ports: 443,
|
||||
domains: ['internal.example.com'],
|
||||
})
|
||||
.setAction({
|
||||
@@ -110,26 +90,54 @@ const route = await client.routes.build()
|
||||
.setEnabled(true)
|
||||
.save();
|
||||
|
||||
await route.toggle(false);
|
||||
await route.update({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 3001 }],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Example: Certificates and Stats
|
||||
System routes from `config`, `email`, and `dns` origins are designed to be toggled, not edited. Full create/update/delete behavior is for routes with origin `api`.
|
||||
|
||||
## API Tokens and Remote Ingress
|
||||
|
||||
```typescript
|
||||
const { certificates, summary } = await client.certificates.list();
|
||||
console.log(summary.valid, summary.failed);
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('automation')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.setExpiresInDays(30)
|
||||
.save();
|
||||
|
||||
const health = await client.stats.getHealth();
|
||||
const recentLogs = await client.logs.getRecent({ level: 'error', limit: 20 });
|
||||
const edge = await client.remoteIngress.build()
|
||||
.setName('edge-eu-1')
|
||||
.setListenPorts([80, 443])
|
||||
.setAutoDerivePorts(true)
|
||||
.setTags(['production', 'eu'])
|
||||
.save();
|
||||
|
||||
const connectionToken = await edge.getConnectionToken();
|
||||
console.log(token.tokenValue, connectionToken);
|
||||
```
|
||||
|
||||
## What This Package Does Not Do
|
||||
## What This Package Is Not
|
||||
|
||||
- It does not start dcrouter.
|
||||
- It does not embed the dashboard.
|
||||
- It does not replace the request interfaces package if you only need raw types.
|
||||
- It does not serve or bundle the Ops dashboard.
|
||||
- It does not replace `@serve.zone/dcrouter-interfaces` when you want raw TypedRequest contracts.
|
||||
|
||||
Use `@serve.zone/dcrouter` to run the server, `@serve.zone/dcrouter-web` for the dashboard bundle/components, and `@serve.zone/dcrouter-interfaces` for raw API contracts.
|
||||
Use `@serve.zone/dcrouter` for the server runtime and `@serve.zone/dcrouter-interfaces` for shared request/data types.
|
||||
|
||||
## Development
|
||||
|
||||
This folder is published from the dcrouter monorepo via `tspublish.json` with order `5`.
|
||||
|
||||
Useful source entry points:
|
||||
|
||||
- `index.ts` exports the public client surface.
|
||||
- `classes.dcrouterapiclient.ts` owns authentication and request dispatch.
|
||||
- `classes.route.ts` owns route resources and builders.
|
||||
- `classes.remoteingress.ts`, `classes.apitoken.ts`, `classes.radius.ts`, and the other manager files wrap focused API domains.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -145,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.
|
||||
|
||||
@@ -6,6 +6,8 @@ export * from './target-profile.js';
|
||||
export * from './vpn.js';
|
||||
export * from './dns-provider.js';
|
||||
export * from './domain.js';
|
||||
export * from './workhoster.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
export * from './email-domain.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -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,6 +110,18 @@ export interface IRouteMetadata {
|
||||
networkTargetName?: string;
|
||||
/** Timestamp of last reference resolution. */
|
||||
lastResolvedAt?: number;
|
||||
/** External route ownership, used by WorkHoster reconciliation. */
|
||||
ownerType?: 'gatewayClient' | 'workhoster' | 'operator' | 'system';
|
||||
gatewayClientType?: TGatewayClientType;
|
||||
gatewayClientId?: string;
|
||||
gatewayClientAppId?: string;
|
||||
/** @deprecated Use gatewayClientType. */
|
||||
workHosterType?: TGatewayClientType;
|
||||
/** @deprecated Use gatewayClientId. */
|
||||
workHosterId?: string;
|
||||
/** @deprecated Use gatewayClientAppId. */
|
||||
workAppId?: string;
|
||||
externalKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +154,7 @@ export interface IApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
policy?: IApiTokenPolicy;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
@@ -145,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 {
|
||||
@@ -149,7 +151,12 @@ export interface IDomainActivity {
|
||||
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 {
|
||||
@@ -208,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;
|
||||
@@ -250,4 +260,4 @@ export interface IVpnStats {
|
||||
registeredClients: number;
|
||||
connectedClients: number;
|
||||
wgListenPort: 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;
|
||||
}
|
||||
+37
-41
@@ -1,20 +1,18 @@
|
||||
# @serve.zone/dcrouter-interfaces
|
||||
|
||||
Shared TypeScript request and data interfaces for dcrouter's OpsServer API. 📡
|
||||
|
||||
This package is the contract layer for typed clients, frontend code, tests, or automation that talks to a running dcrouter instance through TypedRequest.
|
||||
`@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 consume the same interfaces through the main package:
|
||||
The same contracts are exposed through the main package subpath:
|
||||
|
||||
```typescript
|
||||
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
@@ -22,17 +20,29 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
|
||||
## What It Exports
|
||||
|
||||
The package exposes two namespaces from `index.ts`:
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `data` | Shared runtime-shaped types such as route data, auth identity, stats, domains, certificates, VPN, DNS, and email-domain data |
|
||||
| `requests` | TypedRequest request and response contracts for every OpsServer endpoint |
|
||||
| `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`. |
|
||||
|
||||
## Example
|
||||
## 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 * as typedrequest from '@api.global/typedrequest';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||
|
||||
const identity: data.IIdentity = {
|
||||
@@ -41,9 +51,10 @@ const identity: data.IIdentity = {
|
||||
name: 'Admin',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
role: 'admin',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
const request = new typedrequest.TypedRequest<requests.IReq_GetMergedRoutes>(
|
||||
const request = new TypedRequest<requests.IReq_GetMergedRoutes>(
|
||||
'https://dcrouter.example.com/typedrequest',
|
||||
'getMergedRoutes',
|
||||
);
|
||||
@@ -51,42 +62,27 @@ const request = new typedrequest.TypedRequest<requests.IReq_GetMergedRoutes>(
|
||||
const response = await request.fire({ identity });
|
||||
|
||||
for (const route of response.routes) {
|
||||
console.log(route.id, route.origin, route.systemKey, route.enabled);
|
||||
console.log(route.id, route.origin, route.enabled, route.systemKey);
|
||||
}
|
||||
```
|
||||
|
||||
## API Domains Covered
|
||||
## When To Use It
|
||||
|
||||
| Domain | Examples |
|
||||
| --- | --- |
|
||||
| Auth | admin login, logout, identity verification |
|
||||
| Routes | merged routes, create, update, delete, toggle |
|
||||
| Access | API tokens, source profiles, target profiles, network targets |
|
||||
| DNS and domains | providers, domains, DNS records |
|
||||
| Certificates | overview, reprovision, import, export, delete, ACME config |
|
||||
| Email | email operations, email domains |
|
||||
| Remote ingress | edge registrations, status, connection tokens |
|
||||
| VPN | clients, status, telemetry, lifecycle |
|
||||
| RADIUS | clients, VLANs, sessions, accounting |
|
||||
| Observability | stats, logs, health, configuration |
|
||||
- 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.
|
||||
|
||||
## Notable Data Types
|
||||
## Development
|
||||
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `data.IMergedRoute` | Route entry returned by route management, including `origin`, `enabled`, and optional `systemKey` |
|
||||
| `data.IDcRouterRouteConfig` | dcrouter-flavored route config used across the stack |
|
||||
| `data.IRouteMetadata` | Reference metadata connecting routes to source profiles or network targets |
|
||||
| `data.IIdentity` | Admin identity used for authenticated requests |
|
||||
| `data.IApiTokenInfo` | Public token metadata without the secret |
|
||||
This folder is published from the dcrouter monorepo via `tspublish.json` with order `1`, before the client package that imports it.
|
||||
|
||||
## When To Use This Package
|
||||
Useful source entry points:
|
||||
|
||||
- Use it in custom dashboards or CLIs that call TypedRequest directly.
|
||||
- Use it in tests that need strongly typed request payloads or response assertions.
|
||||
- Use it when you want the API contract without pulling in the OO client.
|
||||
|
||||
If you want a higher-level client with managers and resource classes, use `@serve.zone/dcrouter-apiclient` instead.
|
||||
- `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
|
||||
|
||||
@@ -102,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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,4 +18,6 @@ export * from './dns-providers.js';
|
||||
export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domains.js';
|
||||
export * from './email-domains.js';
|
||||
export * from './workhoster.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
/**
|
||||
* dcrouter migration runner.
|
||||
*
|
||||
|
||||
+59
-23
@@ -1,49 +1,85 @@
|
||||
# @serve.zone/dcrouter-migrations
|
||||
|
||||
Migration runner package for dcrouter's smartdata-backed persistence layer. 🧱
|
||||
|
||||
This package provides the startup migration chain that upgrades dcrouter data across releases before the application reads from the database.
|
||||
`@serve.zone/dcrouter-migrations` is dcrouter's versioned SmartMigration chain for persistent database schema and data transitions. The main dcrouter runtime runs it after the database is ready and before DB-backed managers read collections.
|
||||
|
||||
## 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.
|
||||
|
||||
## What It Exports
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-migrations
|
||||
```
|
||||
|
||||
If you boot `DcRouter`, you usually do not install or call this package directly; startup handles it.
|
||||
|
||||
## API
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `createMigrationRunner(db, targetVersion)` | Builds the dcrouter SmartMigration runner for the current application version |
|
||||
| `IMigrationRunner` | Small interface describing the runner's `run()` method |
|
||||
| `IMigrationRunResult` | Logged result shape used after execution |
|
||||
|
||||
## Usage
|
||||
| `createMigrationRunner(db, targetVersion)` | Builds a configured SmartMigration runner for the supplied Smartdata database and target app version. |
|
||||
| `IMigrationRunner` | Minimal runner interface with `run()`. |
|
||||
| `IMigrationRunResult` | Result shape logged after a migration run. |
|
||||
|
||||
```typescript
|
||||
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
|
||||
|
||||
const migration = await createMigrationRunner(db, '13.18.0');
|
||||
const migration = await createMigrationRunner(db, '13.25.0');
|
||||
const result = await migration.run();
|
||||
|
||||
console.log(result.currentVersionBefore, result.currentVersionAfter);
|
||||
```
|
||||
|
||||
## What These Migrations Handle
|
||||
## Current Chain
|
||||
|
||||
The migration chain currently covers dcrouter-specific storage transitions such as:
|
||||
The current migration chain covers:
|
||||
|
||||
- target profile target field renames
|
||||
- domain and DNS record source renames
|
||||
- route collection unification into `RouteDoc`
|
||||
- persisted route metadata backfills such as `origin` and `systemKey`
|
||||
- `TargetProfileDoc.targets[].host` to `TargetProfileDoc.targets[].ip`
|
||||
- legacy domain source `manual` to `dcrouter`
|
||||
- legacy DNS record source `manual` to `local`
|
||||
- route collection unification from `StoredRouteDoc` to `RouteDoc`
|
||||
- route `origin` backfill for migrated API routes
|
||||
- `systemKey` backfill for persisted config, email, and DNS routes
|
||||
|
||||
## Important Behavior
|
||||
## Migration Rules
|
||||
|
||||
- fresh installs are stamped directly to the current target version
|
||||
- migration steps are registered in strict version order
|
||||
- migrations run before services load DB-backed state
|
||||
- route-related migrations use smartdata collection names exactly as declared in code
|
||||
All schema migrations must live in `ts_migrations/index.ts` as SmartMigration steps. Do not put migration logic in application services, managers, startup hooks, or document classes.
|
||||
|
||||
If you are embedding dcrouter's DB layer outside the main runtime, run this package before any feature code assumes the latest schema.
|
||||
Every step must be idempotent. SmartMigration may re-run steps during skip-forward or resume flows, so an already-migrated database must remain safe.
|
||||
|
||||
The `.to()` version of a step must match the release version that ships the migration so SmartMigration can plan the step correctly.
|
||||
|
||||
## Collection Name Warning
|
||||
|
||||
smartdata uses the exact class name as the MongoDB collection name. Do not lowercase new collection references.
|
||||
|
||||
Examples:
|
||||
|
||||
| Document class | MongoDB collection |
|
||||
| --- | --- |
|
||||
| `StoredRouteDoc` | `StoredRouteDoc` |
|
||||
| `TargetProfileDoc` | `TargetProfileDoc` |
|
||||
| `RouteDoc` | `RouteDoc` |
|
||||
|
||||
When writing migrations in `ts_migrations/index.ts`, use exact class-name casing in calls such as `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
|
||||
|
||||
Historical migration steps may still reference older lowercased collections from earlier behavior. Do not copy that pattern for new migrations.
|
||||
|
||||
## When To Use This Package Directly
|
||||
|
||||
- You are testing dcrouter schema transitions without booting the full runtime.
|
||||
- You are embedding dcrouter persistence in another process and need the same version chain.
|
||||
- You are authoring a migration and want a focused test harness around `createMigrationRunner()`.
|
||||
|
||||
## Development
|
||||
|
||||
This folder is published from the dcrouter monorepo via `tspublish.json` with order `2`.
|
||||
|
||||
Useful source entry points:
|
||||
|
||||
- `index.ts` defines the public runner factory and all migration steps.
|
||||
- `../AGENTS.md` contains the local migration authoring rules that must be followed.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -59,7 +95,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.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.19.0',
|
||||
version: '13.26.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+266
-22
@@ -54,6 +54,7 @@ export interface INetworkState {
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||
throughputByIP: Array<{ ip: string; in: number; out: number }>;
|
||||
ipIntelligence: interfaces.data.IIpIntelligenceRecord[];
|
||||
domainActivity: interfaces.data.IDomainActivity[];
|
||||
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
|
||||
requestsPerSecond: number;
|
||||
@@ -66,6 +67,16 @@ export interface INetworkState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ISecurityPolicyState {
|
||||
rules: interfaces.data.ISecurityBlockRule[];
|
||||
ipIntelligence: interfaces.data.IIpIntelligenceRecord[];
|
||||
compiledPolicy: interfaces.data.ISecurityCompiledPolicy | null;
|
||||
auditEvents: interfaces.data.ISecurityPolicyAuditEvent[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export interface ICertificateState {
|
||||
certificates: interfaces.requests.ICertificateInfo[];
|
||||
summary: { total: number; valid: number; expiring: number; expired: number; failed: number; unknown: number };
|
||||
@@ -164,6 +175,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
||||
topIPs: [],
|
||||
topIPsByBandwidth: [],
|
||||
throughputByIP: [],
|
||||
ipIntelligence: [],
|
||||
domainActivity: [],
|
||||
throughputHistory: [],
|
||||
requestsPerSecond: 0,
|
||||
@@ -178,6 +190,20 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
||||
'soft'
|
||||
);
|
||||
|
||||
export const securityPolicyStatePart = await appState.getStatePart<ISecurityPolicyState>(
|
||||
'securityPolicy',
|
||||
{
|
||||
rules: [],
|
||||
ipIntelligence: [],
|
||||
compiledPolicy: null,
|
||||
auditEvents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
||||
'emailOps',
|
||||
{
|
||||
@@ -512,43 +538,56 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
// Fetch active connections using the existing endpoint
|
||||
const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetActiveConnections
|
||||
>('/typedrequest', 'getActiveConnections');
|
||||
|
||||
const connectionsResponse = await connectionsRequest.fire({
|
||||
identity: context.identity,
|
||||
});
|
||||
|
||||
// Get network stats for throughput and IP data
|
||||
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetNetworkStats
|
||||
>('/typedrequest', 'getNetworkStats');
|
||||
|
||||
const networkStatsResponse = await networkStatsRequest.fire({
|
||||
identity: context.identity,
|
||||
});
|
||||
const ipIntelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListIpIntelligence
|
||||
>('/typedrequest', 'listIpIntelligence');
|
||||
|
||||
const [networkStatsResponse, ipIntelligenceResponse] = await Promise.all([
|
||||
networkStatsRequest.fire({
|
||||
identity: context.identity,
|
||||
}),
|
||||
ipIntelligenceRequest.fire({
|
||||
identity: context.identity,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Use the connections data for the connection list
|
||||
// and network stats for throughput and IP analytics
|
||||
const connectionsByIP: { [ip: string]: number } = {};
|
||||
const throughputByIP = new Map<string, { in: number; out: number }>();
|
||||
for (const item of networkStatsResponse.throughputByIP || []) {
|
||||
throughputByIP.set(item.ip, { in: item.in, out: item.out });
|
||||
}
|
||||
|
||||
// Build connectionsByIP from network stats if available
|
||||
if (networkStatsResponse.connectionsByIP && Array.isArray(networkStatsResponse.connectionsByIP)) {
|
||||
networkStatsResponse.connectionsByIP.forEach((item: { ip: string; count: number }) => {
|
||||
connectionsByIP[item.ip] = item.count;
|
||||
});
|
||||
} else {
|
||||
// Fallback: calculate from connections
|
||||
connectionsResponse.connections.forEach(conn => {
|
||||
const ip = conn.remoteAddress;
|
||||
connectionsByIP[ip] = (connectionsByIP[ip] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
const connections: interfaces.data.IConnectionInfo[] = Object.entries(connectionsByIP).map(([ip, count]) => {
|
||||
const tp = throughputByIP.get(ip);
|
||||
return {
|
||||
id: `ip-${ip}`,
|
||||
remoteAddress: ip,
|
||||
localAddress: 'server',
|
||||
startTime: 0,
|
||||
protocol: 'https',
|
||||
state: 'connected',
|
||||
bytesReceived: tp?.in || 0,
|
||||
bytesSent: tp?.out || 0,
|
||||
connectionCount: count,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
connections: connectionsResponse.connections,
|
||||
connections,
|
||||
connectionsByIP,
|
||||
throughputRate: networkStatsResponse.throughputRate || { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
totalBytes: networkStatsResponse.totalDataTransferred
|
||||
@@ -557,6 +596,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
topIPs: networkStatsResponse.topIPs || [],
|
||||
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
|
||||
throughputByIP: networkStatsResponse.throughputByIP || [],
|
||||
ipIntelligence: ipIntelligenceResponse.records || [],
|
||||
domainActivity: networkStatsResponse.domainActivity || [],
|
||||
throughputHistory: networkStatsResponse.throughputHistory || [],
|
||||
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
||||
@@ -578,6 +618,182 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Security Policy Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
|
||||
async (statePartArg): Promise<ISecurityPolicyState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListSecurityBlockRules
|
||||
>('/typedrequest', 'listSecurityBlockRules');
|
||||
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListIpIntelligence
|
||||
>('/typedrequest', 'listIpIntelligence');
|
||||
const compiledPolicyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetCompiledSecurityPolicy
|
||||
>('/typedrequest', 'getCompiledSecurityPolicy');
|
||||
const auditRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListSecurityPolicyAudit
|
||||
>('/typedrequest', 'listSecurityPolicyAudit');
|
||||
|
||||
const [rulesResponse, intelligenceResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
|
||||
rulesRequest.fire({ identity: context.identity }),
|
||||
intelligenceRequest.fire({ identity: context.identity }),
|
||||
compiledPolicyRequest.fire({ identity: context.identity }),
|
||||
auditRequest.fire({ identity: context.identity, limit: 100 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
rules: rulesResponse.rules || [],
|
||||
ipIntelligence: intelligenceResponse.records || [],
|
||||
compiledPolicy: compiledPolicyResponse.policy,
|
||||
auditEvents: auditResponse.events || [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch security policy',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const createSecurityBlockRuleAction = securityPolicyStatePart.createAction<{
|
||||
type: interfaces.data.TSecurityBlockRuleType;
|
||||
value: string;
|
||||
matchMode?: interfaces.data.TSecurityBlockRuleMatchMode;
|
||||
reason?: string;
|
||||
enabled?: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ISecurityPolicyState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateSecurityBlockRule
|
||||
>('/typedrequest', 'createSecurityBlockRule');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
type: dataArg.type,
|
||||
value: dataArg.value,
|
||||
matchMode: dataArg.matchMode,
|
||||
reason: dataArg.reason,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
return { ...currentState, error: response.message || 'Failed to create security block rule' };
|
||||
}
|
||||
|
||||
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to create security block rule',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const updateSecurityBlockRuleAction = securityPolicyStatePart.createAction<{
|
||||
id: string;
|
||||
value?: string;
|
||||
matchMode?: interfaces.data.TSecurityBlockRuleMatchMode;
|
||||
reason?: string;
|
||||
enabled?: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ISecurityPolicyState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateSecurityBlockRule
|
||||
>('/typedrequest', 'updateSecurityBlockRule');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
id: dataArg.id,
|
||||
value: dataArg.value,
|
||||
matchMode: dataArg.matchMode,
|
||||
reason: dataArg.reason,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
return { ...currentState, error: response.message || 'Failed to update security block rule' };
|
||||
}
|
||||
|
||||
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to update security block rule',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteSecurityBlockRuleAction = securityPolicyStatePart.createAction<string>(
|
||||
async (statePartArg, ruleId, actionContext): Promise<ISecurityPolicyState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteSecurityBlockRule
|
||||
>('/typedrequest', 'deleteSecurityBlockRule');
|
||||
|
||||
const response = await request.fire({ identity: context.identity, id: ruleId });
|
||||
if (!response.success) {
|
||||
return { ...currentState, error: response.message || 'Failed to delete security block rule' };
|
||||
}
|
||||
|
||||
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete security block rule',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const refreshIpIntelligenceAction = securityPolicyStatePart.createAction<string>(
|
||||
async (statePartArg, ipAddress, actionContext): Promise<ISecurityPolicyState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_RefreshIpIntelligence
|
||||
>('/typedrequest', 'refreshIpIntelligence');
|
||||
const response = await request.fire({ identity: context.identity, ipAddress });
|
||||
if (!response.success) {
|
||||
return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to refresh IP intelligence',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Email Operations Actions
|
||||
// ============================================================================
|
||||
@@ -2290,7 +2506,12 @@ export const fetchUsersAction = usersStatePart.createAction(async (statePartArg)
|
||||
}
|
||||
});
|
||||
|
||||
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
|
||||
export async function createApiToken(
|
||||
name: string,
|
||||
scopes: interfaces.data.TApiTokenScope[],
|
||||
expiresInDays?: number | null,
|
||||
policy?: any,
|
||||
) {
|
||||
const context = getActionContext();
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateApiToken
|
||||
@@ -2300,6 +2521,7 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT
|
||||
identity: context.identity!,
|
||||
name,
|
||||
scopes,
|
||||
policy,
|
||||
expiresInDays,
|
||||
});
|
||||
}
|
||||
@@ -2589,7 +2811,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
email: true,
|
||||
dns: true,
|
||||
security: true,
|
||||
network: currentView === 'network', // Only fetch network if on network view
|
||||
network: currentView === 'network' && currentSubview === 'activity',
|
||||
radius: true,
|
||||
vpn: true,
|
||||
},
|
||||
@@ -2617,7 +2839,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
|
||||
// Build connectionsByIP from connectionDetails (now populated with real per-IP data)
|
||||
network.connectionDetails.forEach(conn => {
|
||||
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1;
|
||||
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + (conn.connectionCount || 1);
|
||||
});
|
||||
|
||||
// Build connections from connectionDetails (real per-IP aggregates)
|
||||
@@ -2630,6 +2852,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
state: conn.state as any,
|
||||
bytesReceived: conn.bytesIn,
|
||||
bytesSent: conn.bytesOut,
|
||||
connectionCount: conn.connectionCount,
|
||||
}));
|
||||
|
||||
networkStatePart.setState({
|
||||
@@ -2660,6 +2883,27 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListIpIntelligence
|
||||
>('/typedrequest', 'listIpIntelligence');
|
||||
const intelligenceResponse = await intelligenceRequest.fire({ identity: context.identity });
|
||||
networkStatePart.setState({
|
||||
...networkStatePart.getState()!,
|
||||
ipIntelligence: intelligenceResponse.records || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('IP intelligence refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentView === 'security') {
|
||||
try {
|
||||
await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null);
|
||||
} catch (error) {
|
||||
console.error('Security policy refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh certificate data if on Domains > Certificates subview
|
||||
|
||||
@@ -199,12 +199,25 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
private async showCreateTokenDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const allScopes: TApiTokenScope[] = [
|
||||
const allScopes = [
|
||||
'*',
|
||||
'routes:read',
|
||||
'routes:write',
|
||||
'config:read',
|
||||
'certificates:read',
|
||||
'certificates:write',
|
||||
'tokens:read',
|
||||
'tokens:manage',
|
||||
'domains:read',
|
||||
'domains:write',
|
||||
'dns-records:read',
|
||||
'dns-records:write',
|
||||
'email-domains:read',
|
||||
'email-domains:write',
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
@@ -218,10 +231,15 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
<dees-input-tags
|
||||
.key=${'scopes'}
|
||||
.label=${'Token Scopes'}
|
||||
.value=${['routes:read', 'routes:write']}
|
||||
.value=${['gateway-clients:read', 'gateway-clients:write']}
|
||||
.suggestions=${allScopes}
|
||||
.required=${true}
|
||||
></dees-input-tags>
|
||||
<dees-input-text .key=${'policyRole'} .label=${'Policy Role'} .description=${'admin, gatewayClient, or operator'}></dees-input-text>
|
||||
<dees-input-text .key=${'gatewayClientType'} .label=${'Gateway Client Type'} .description=${'For gatewayClient tokens: onebox, cloudly, or custom'}></dees-input-text>
|
||||
<dees-input-text .key=${'gatewayClientId'} .label=${'Gateway Client ID'} .description=${'Required for gatewayClient tokens'}></dees-input-text>
|
||||
<dees-input-text .key=${'hostnamePatterns'} .label=${'Hostname Patterns'} .description=${'Comma separated, e.g. *.apps.example.com'}></dees-input-text>
|
||||
<dees-input-text .key=${'allowedRouteTarget'} .label=${'Allowed Route Target'} .description=${'Optional host:ports, e.g. 203.0.113.10:80,443'}></dees-input-text>
|
||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -247,6 +265,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || [];
|
||||
const scopes = rawScopes
|
||||
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
|
||||
const policy = this.buildPolicy(formData, scopes);
|
||||
|
||||
const expiresInDays = formData.expiresInDays
|
||||
? parseInt(formData.expiresInDays, 10)
|
||||
@@ -255,7 +274,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
await modalArg.destroy();
|
||||
|
||||
try {
|
||||
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays);
|
||||
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays, policy);
|
||||
if (response.success && response.tokenValue) {
|
||||
// Refresh the list first so it's ready when user dismisses the modal
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||
@@ -289,6 +308,42 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
private buildPolicy(formData: any, scopes: TApiTokenScope[]): any | undefined {
|
||||
const role = String(formData.policyRole || '').trim();
|
||||
if (!role) return undefined;
|
||||
const policy: any = {
|
||||
role,
|
||||
scopes,
|
||||
};
|
||||
if (role === 'gatewayClient') {
|
||||
const type = String(formData.gatewayClientType || 'onebox').trim() as 'onebox' | 'cloudly' | 'custom';
|
||||
const id = String(formData.gatewayClientId || '').trim();
|
||||
if (id) {
|
||||
policy.gatewayClient = { type, id };
|
||||
}
|
||||
policy.hostnamePatterns = String(formData.hostnamePatterns || '')
|
||||
.split(',')
|
||||
.map((pattern) => pattern.trim())
|
||||
.filter(Boolean);
|
||||
const target = String(formData.allowedRouteTarget || '').trim();
|
||||
if (target.includes(':')) {
|
||||
const [host, portsValue] = target.split(':');
|
||||
policy.allowedRouteTargets = [{
|
||||
host: host.trim(),
|
||||
ports: portsValue.split(',').map((port) => Number(port.trim())).filter((port) => Number.isInteger(port)),
|
||||
}];
|
||||
}
|
||||
policy.capabilities = {
|
||||
readDomains: true,
|
||||
readDnsRecords: true,
|
||||
syncRoutes: true,
|
||||
syncDnsRecords: false,
|
||||
requestCertificates: false,
|
||||
};
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
// Subscribe and track unsubscribe functions
|
||||
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
|
||||
this.statsState = state;
|
||||
this.updateNetworkData();
|
||||
});
|
||||
this.rxSubscriptions.push(statsUnsubscribe);
|
||||
|
||||
@@ -256,6 +255,17 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
|
||||
}
|
||||
|
||||
.intelligenceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#eef2ff', '#1e1b4b')};
|
||||
color: ${cssManager.bdTheme('#4338ca', '#a5b4fc')};
|
||||
}
|
||||
|
||||
.protocolChartGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -346,6 +356,100 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
private formatOptional(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
private formatDateTime(timestamp?: number | null): string {
|
||||
return timestamp ? new Date(timestamp).toLocaleString() : '-';
|
||||
}
|
||||
|
||||
private getIpIntelligence(ip: string): interfaces.data.IIpIntelligenceRecord | undefined {
|
||||
return this.networkState.ipIntelligence?.find((record) => record.ipAddress === ip);
|
||||
}
|
||||
|
||||
private getIpOrganization(record?: interfaces.data.IIpIntelligenceRecord): string {
|
||||
return record?.asnOrg || record?.registrantOrg || '';
|
||||
}
|
||||
|
||||
private getIpIntelligenceColumns(ip: string): Record<string, unknown> {
|
||||
const record = this.getIpIntelligence(ip);
|
||||
const organization = this.getIpOrganization(record);
|
||||
return {
|
||||
'Intelligence': record
|
||||
? html`<span class="intelligenceBadge">${this.formatOptional(organization || record.countryCode || 'Known')}</span>`
|
||||
: html`<span class="statusBadge warning">Enriching...</span>`,
|
||||
'ASN': record?.asn ? `AS${record.asn}` : '-',
|
||||
'Organization': this.formatOptional(organization),
|
||||
'Country': this.formatOptional(record?.countryCode || record?.country),
|
||||
'Network Range': this.formatOptional(record?.networkRange),
|
||||
'Last Seen': this.formatDateTime(record?.lastSeenAt),
|
||||
};
|
||||
}
|
||||
|
||||
private getIpDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Refresh Intelligence',
|
||||
iconName: 'lucide:refresh-cw',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const ip = actionData.item.ip;
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.refreshIpIntelligenceAction, ip);
|
||||
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block IP',
|
||||
iconName: 'lucide:shield-ban',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.createBlockRuleDialog('ip', actionData.item.ip, 'Blocked from Network Activity');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block Network Range',
|
||||
iconName: 'lucide:network',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)?.networkRange),
|
||||
actionFunc: async (actionData: any) => {
|
||||
const record = this.getIpIntelligence(actionData.item.ip);
|
||||
await this.createBlockRuleDialog('cidr', record!.networkRange!, 'Blocked network range from Network Activity');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block ASN',
|
||||
iconName: 'lucide:radio-tower',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)?.asn),
|
||||
actionFunc: async (actionData: any) => {
|
||||
const record = this.getIpIntelligence(actionData.item.ip);
|
||||
await this.createBlockRuleDialog('asn', String(record!.asn), 'Blocked ASN from Network Activity');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block Organization',
|
||||
iconName: 'lucide:building-2',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpOrganization(this.getIpIntelligence(actionData.item.ip))),
|
||||
actionFunc: async (actionData: any) => {
|
||||
const record = this.getIpIntelligence(actionData.item.ip);
|
||||
await this.createBlockRuleDialog('organization', this.getIpOrganization(record), 'Blocked organization from Network Activity');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View Intelligence',
|
||||
iconName: 'lucide:info',
|
||||
type: ['doubleClick', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)),
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.showIpIntelligenceDetails(actionData.item.ip);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private calculateThroughput(): { in: number; out: number } {
|
||||
// Use real throughput data from network state
|
||||
return {
|
||||
@@ -501,10 +605,12 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
'Bandwidth In': bw ? this.formatBitsPerSecond(bw.in) : '0 bit/s',
|
||||
'Bandwidth Out': bw ? this.formatBitsPerSecond(bw.out) : '0 bit/s',
|
||||
'Share': totalConnections > 0 ? ((ipData.count / totalConnections) * 100).toFixed(1) + '%' : '0%',
|
||||
...this.getIpIntelligenceColumns(ipData.ip),
|
||||
};
|
||||
}}
|
||||
.dataActions=${this.getIpDataActions()}
|
||||
heading1="Top Connected IPs"
|
||||
heading2="IPs with most active connections and bandwidth"
|
||||
heading2="IPs with most active connections, bandwidth, and intelligence"
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
.pagination=${false}
|
||||
@@ -530,10 +636,12 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
'Bandwidth Out': this.formatBitsPerSecond(ipData.bwOut),
|
||||
'Total Bandwidth': this.formatBitsPerSecond(ipData.bwIn + ipData.bwOut),
|
||||
'Connections': ipData.count,
|
||||
...this.getIpIntelligenceColumns(ipData.ip),
|
||||
};
|
||||
}}
|
||||
.dataActions=${this.getIpDataActions()}
|
||||
heading1="Top IPs by Bandwidth"
|
||||
heading2="IPs with highest throughput"
|
||||
heading2="IPs with highest throughput and intelligence"
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
.pagination=${false}
|
||||
@@ -560,6 +668,8 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
|
||||
'Transferred / min': this.formatBytes(totalBytesPerMin),
|
||||
'Connections': item.activeConnections,
|
||||
'Req/s': item.requestsPerSecond != null ? item.requestsPerSecond.toFixed(1) : '-',
|
||||
'Req/min': item.requestsLastMinute != null ? item.requestsLastMinute.toFixed(0) : '-',
|
||||
'Requests': item.requestCount?.toLocaleString() ?? '0',
|
||||
'Routes': item.routeCount,
|
||||
};
|
||||
@@ -583,7 +693,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${backends}
|
||||
.rowKey=${'backend'}
|
||||
.rowKey=${'id'}
|
||||
.highlightUpdates=${'flash'}
|
||||
.displayFunction=${(item: interfaces.data.IBackendInfo) => {
|
||||
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
|
||||
@@ -677,6 +787,114 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
private getDropdownKey(value: any): string {
|
||||
return typeof value === 'string' ? value : value?.key || '';
|
||||
}
|
||||
|
||||
private async createBlockRuleDialog(
|
||||
type: interfaces.data.TSecurityBlockRuleType,
|
||||
value: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const typeOptions = [
|
||||
{ key: 'ip', option: 'IP address' },
|
||||
{ key: 'cidr', option: 'CIDR / network range' },
|
||||
{ key: 'asn', option: 'ASN' },
|
||||
{ key: 'organization', option: 'Organization' },
|
||||
];
|
||||
const matchModeOptions = [
|
||||
{ key: 'contains', option: 'Organization contains value' },
|
||||
{ key: 'exact', option: 'Organization exactly matches value' },
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Create Security Block Rule',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-dropdown
|
||||
.key=${'type'}
|
||||
.label=${'Rule Type'}
|
||||
.options=${typeOptions}
|
||||
.selectedOption=${typeOptions.find((option) => option.key === type)}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text .key=${'value'} .label=${'Value'} .value=${value} .required=${true}></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.key=${'matchMode'}
|
||||
.label=${'Organization Match Mode'}
|
||||
.description=${'Only used for organization rules'}
|
||||
.options=${matchModeOptions}
|
||||
.selectedOption=${matchModeOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text .key=${'reason'} .label=${'Reason'} .value=${reason}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'enabled'} .label=${'Enable immediately'} .value=${true}></dees-input-checkbox>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:shield-ban',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const selectedType = this.getDropdownKey(data.type) as interfaces.data.TSecurityBlockRuleType;
|
||||
const selectedValue = String(data.value || '').trim();
|
||||
if (!selectedType || !selectedValue) return;
|
||||
const matchMode = selectedType === 'organization'
|
||||
? this.getDropdownKey(data.matchMode) as interfaces.data.TSecurityBlockRuleMatchMode
|
||||
: undefined;
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.createSecurityBlockRuleAction, {
|
||||
type: selectedType,
|
||||
value: selectedValue,
|
||||
matchMode,
|
||||
reason: String(data.reason || '').trim() || undefined,
|
||||
enabled: data.enabled !== false,
|
||||
});
|
||||
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showIpIntelligenceDetails(ip: string): Promise<void> {
|
||||
const record = this.getIpIntelligence(ip);
|
||||
if (!record) return;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: `IP Intelligence: ${ip}`,
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<dees-dataview-codebox
|
||||
.heading=${'Intelligence Record'}
|
||||
progLang="json"
|
||||
.codeToDisplay=${JSON.stringify(record, null, 2)}
|
||||
></dees-dataview-codebox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Copy Abuse Contact',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => {
|
||||
if (record.abuseContact) await navigator.clipboard.writeText(record.abuseContact);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block IP',
|
||||
iconName: 'lucide:shield-ban',
|
||||
action: async () => {
|
||||
await this.createBlockRuleDialog('ip', record.ipAddress, 'Blocked from IP intelligence details');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async updateNetworkData() {
|
||||
// Track requests/sec history for the trend sparkline (moved out of render)
|
||||
const reqPerSec = this.networkState.requestsPerSecond || 0;
|
||||
@@ -707,6 +925,9 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
}
|
||||
|
||||
const throughput = this.calculateThroughput();
|
||||
if (this.networkState.lastUpdated && now - this.networkState.lastUpdated > 3000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to Mbps (bytes * 8 / 1,000,000)
|
||||
const throughputInMbps = (throughput.in * 8) / 1000000;
|
||||
|
||||
@@ -125,6 +125,18 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
color: ${cssManager.bdTheme('#047857', '#34d399')};
|
||||
border: 1px dashed ${cssManager.bdTheme('#6ee7b7', '#065f46')};
|
||||
}
|
||||
|
||||
.metricStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.metricMuted {
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -226,9 +238,13 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
|
||||
name: edge.name,
|
||||
status: this.getEdgeStatusHtml(edge),
|
||||
transport: this.getTransportHtml(edge.id),
|
||||
publicIp: this.getEdgePublicIp(edge.id),
|
||||
ports: this.getPortsHtml(edge),
|
||||
tunnels: this.getEdgeTunnelCount(edge.id),
|
||||
window: this.getWindowHtml(edge.id),
|
||||
queues: this.getQueuesHtml(edge.id),
|
||||
traffic: this.getTrafficHtml(edge.id),
|
||||
lastHeartbeat: this.getLastHeartbeat(edge.id),
|
||||
})}
|
||||
.dataActions=${[
|
||||
@@ -459,6 +475,46 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
return status?.activeTunnels || 0;
|
||||
}
|
||||
|
||||
private getTransportHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected) return '-';
|
||||
const mode = status.transportMode || 'unknown';
|
||||
const label = mode === 'quic' ? 'QUIC' : mode === 'tcpTls' ? 'TCP/TLS' : mode;
|
||||
return html`<div class="metricStack"><strong>${label}</strong><span class="metricMuted">${status.fallbackUsed ? 'fallback' : status.performance?.profile || 'default'}</span></div>`;
|
||||
}
|
||||
|
||||
private getWindowHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected || !status.flowControl) return '-';
|
||||
if (!status.flowControl.applies) {
|
||||
return html`<div class="metricStack"><span>native QUIC</span><span class="metricMuted">max ${status.performance?.maxStreamsPerEdge || '-'} streams</span></div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="metricStack">
|
||||
<span>${this.formatBytes(status.flowControl.currentWindowBytes)} window</span>
|
||||
<span class="metricMuted">${this.formatBytes(status.flowControl.estimatedInFlightBytes)} est. in-flight</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getQueuesHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected || !status.queues) return '-';
|
||||
return html`<div class="metricStack"><span>C ${status.queues.ctrlQueueDepth} / D ${status.queues.dataQueueDepth}</span><span class="metricMuted">S ${status.queues.sustainedQueueDepth}</span></div>`;
|
||||
}
|
||||
|
||||
private getTrafficHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected || !status.traffic) return '-';
|
||||
const drops = (status.traffic.rejectedStreams || 0) + (status.udp?.droppedDatagrams || 0);
|
||||
return html`
|
||||
<div class="metricStack">
|
||||
<span>${this.formatBytes(status.traffic.bytesIn)} in / ${this.formatBytes(status.traffic.bytesOut)} out</span>
|
||||
<span class="metricMuted">${drops} rejected/dropped</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getLastHeartbeat(edgeId: string): string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.lastHeartbeat) return '-';
|
||||
@@ -467,4 +523,16 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
|
||||
return `${Math.floor(ago / 3600000)}h ago`;
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value = value / 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,90 @@ import {
|
||||
|
||||
// TLS dropdown options shared by create and edit dialogs
|
||||
const tlsModeOptions = [
|
||||
{ key: 'none', option: '(none — no TLS)' },
|
||||
{ key: 'passthrough', option: 'Passthrough' },
|
||||
{ key: 'terminate', option: 'Terminate' },
|
||||
{ key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt' },
|
||||
{ key: 'none', option: '(none — plain TCP/HTTP, use for SSH)' },
|
||||
{ key: 'passthrough', option: 'Passthrough (TLS only)' },
|
||||
{ key: 'terminate', option: 'Terminate TLS' },
|
||||
{ key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt TLS' },
|
||||
];
|
||||
const tlsCertOptions = [
|
||||
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
|
||||
{ key: 'custom', option: 'Custom certificate' },
|
||||
];
|
||||
|
||||
function getDropdownKey(value: any): string {
|
||||
return typeof value === 'string' ? value : value?.key || '';
|
||||
}
|
||||
|
||||
function parseTargetPort(value: any): number | undefined {
|
||||
const parsed = typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? parseInt(value.trim(), 10)
|
||||
: Number.NaN;
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getRouteTargetInputs(formEl: any) {
|
||||
const textInputs = Array.from(formEl.querySelectorAll('dees-input-text')) as any[];
|
||||
const checkboxInputs = Array.from(formEl.querySelectorAll('dees-input-checkbox')) as any[];
|
||||
return {
|
||||
hostInput: textInputs.find((input) => input.key === 'targetHost'),
|
||||
portInput: textInputs.find((input) => input.key === 'targetPort'),
|
||||
preservePortInput: checkboxInputs.find((input) => input.key === 'preserveMatchPort'),
|
||||
};
|
||||
}
|
||||
|
||||
function setupTargetInputState(formEl: any) {
|
||||
const updateState = async () => {
|
||||
const data = await formEl.collectFormData();
|
||||
const contentEl = formEl.closest('.content') || formEl.parentElement;
|
||||
const usesNetworkTarget = !!getDropdownKey(data.networkTargetRef);
|
||||
const preserveMatchPort = !usesNetworkTarget && Boolean(data.preserveMatchPort);
|
||||
const { hostInput, portInput, preservePortInput } = getRouteTargetInputs(formEl);
|
||||
const hostDescription = usesNetworkTarget
|
||||
? 'Controlled by the selected network target'
|
||||
: 'Used when no network target is selected';
|
||||
const portDescription = usesNetworkTarget
|
||||
? 'Controlled by the selected network target'
|
||||
: preserveMatchPort
|
||||
? 'Forwarded to the backend on the same port the client matched'
|
||||
: 'Used when no network target is selected';
|
||||
|
||||
if (hostInput) {
|
||||
hostInput.disabled = usesNetworkTarget;
|
||||
hostInput.required = !usesNetworkTarget;
|
||||
hostInput.description = hostDescription;
|
||||
}
|
||||
if (portInput) {
|
||||
portInput.disabled = usesNetworkTarget || preserveMatchPort;
|
||||
portInput.required = !usesNetworkTarget && !preserveMatchPort;
|
||||
portInput.description = portDescription;
|
||||
}
|
||||
if (preservePortInput) {
|
||||
preservePortInput.disabled = usesNetworkTarget;
|
||||
preservePortInput.description = usesNetworkTarget
|
||||
? 'Unavailable when a network target is selected'
|
||||
: 'Forward to the backend using the same port that matched this route';
|
||||
if (usesNetworkTarget) {
|
||||
preservePortInput.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const remoteIngressGroup = contentEl?.querySelector('.remoteIngressGroup') as HTMLElement | null;
|
||||
if (remoteIngressGroup) {
|
||||
remoteIngressGroup.style.display = Boolean(data.remoteIngressEnabled) ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
await formEl.updateRequiredStatus?.();
|
||||
};
|
||||
|
||||
formEl.changeSubject.subscribe(() => updateState());
|
||||
updateState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle TLS form field visibility based on selected TLS mode and certificate type.
|
||||
*/
|
||||
@@ -411,10 +485,13 @@ export class OpsViewRoutes extends DeesElement {
|
||||
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
|
||||
: [];
|
||||
const firstTarget = route.action.targets?.[0];
|
||||
const currentPreserveMatchPort = firstTarget?.port === 'preserve';
|
||||
const currentTargetHost = firstTarget
|
||||
? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
|
||||
: '';
|
||||
const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : '';
|
||||
const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
|
||||
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
|
||||
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
|
||||
|
||||
// Compute current TLS state for pre-population
|
||||
const currentTls = (route.action as any).tls;
|
||||
@@ -439,6 +516,11 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
|
||||
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
|
||||
<div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'} .value=${currentEdgeFilter}></dees-input-list>
|
||||
</div>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
|
||||
@@ -470,6 +552,24 @@ export class OpsViewRoutes extends DeesElement {
|
||||
: [];
|
||||
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
|
||||
|
||||
const profileKey = getDropdownKey(formData.sourceProfileRef);
|
||||
const targetKey = getDropdownKey(formData.networkTargetRef);
|
||||
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
|
||||
const targetPort = preserveMatchPort
|
||||
? 'preserve'
|
||||
: parseTargetPort(formData.targetPort)
|
||||
?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined);
|
||||
|
||||
if (targetPort === undefined) {
|
||||
alert('Target Port must be a valid port number when no network target is selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled);
|
||||
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
||||
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const updatedRoute: any = {
|
||||
name: formData.name,
|
||||
match: {
|
||||
@@ -480,11 +580,17 @@ export class OpsViewRoutes extends DeesElement {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{
|
||||
host: formData.targetHost || 'localhost',
|
||||
port: parseInt(formData.targetPort, 10) || 443,
|
||||
host: formData.targetHost || currentTargetHost || 'localhost',
|
||||
port: targetPort,
|
||||
},
|
||||
],
|
||||
},
|
||||
remoteIngress: remoteIngressEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}),
|
||||
}
|
||||
: null,
|
||||
...(priority != null && !isNaN(priority) ? { priority } : {}),
|
||||
};
|
||||
|
||||
@@ -508,15 +614,17 @@ export class OpsViewRoutes extends DeesElement {
|
||||
}
|
||||
|
||||
const metadata: any = {};
|
||||
const profileRefValue = formData.sourceProfileRef as any;
|
||||
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
|
||||
if (profileKey) {
|
||||
metadata.sourceProfileRef = profileKey;
|
||||
} else if (merged.metadata?.sourceProfileRef) {
|
||||
metadata.sourceProfileRef = '';
|
||||
metadata.sourceProfileName = '';
|
||||
}
|
||||
const targetRefValue = formData.networkTargetRef as any;
|
||||
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
} else if (merged.metadata?.networkTargetRef) {
|
||||
metadata.networkTargetRef = '';
|
||||
metadata.networkTargetName = '';
|
||||
}
|
||||
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
@@ -537,6 +645,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
if (editForm) {
|
||||
await editForm.updateComplete;
|
||||
setupTlsVisibility(editForm);
|
||||
setupTargetInputState(editForm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +682,11 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
|
||||
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
|
||||
<div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'}></dees-input-list>
|
||||
</div>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
|
||||
@@ -604,6 +718,24 @@ export class OpsViewRoutes extends DeesElement {
|
||||
: [];
|
||||
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
|
||||
|
||||
const profileKey = getDropdownKey(formData.sourceProfileRef);
|
||||
const targetKey = getDropdownKey(formData.networkTargetRef);
|
||||
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
|
||||
const targetPort = preserveMatchPort
|
||||
? 'preserve'
|
||||
: parseTargetPort(formData.targetPort)
|
||||
?? (targetKey ? ports[0] : undefined);
|
||||
|
||||
if (targetPort === undefined) {
|
||||
alert('Target Port must be a valid port number when no network target is selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled);
|
||||
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
||||
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const route: any = {
|
||||
name: formData.name,
|
||||
match: {
|
||||
@@ -615,10 +747,18 @@ export class OpsViewRoutes extends DeesElement {
|
||||
targets: [
|
||||
{
|
||||
host: formData.targetHost || 'localhost',
|
||||
port: parseInt(formData.targetPort, 10) || 443,
|
||||
port: targetPort,
|
||||
},
|
||||
],
|
||||
},
|
||||
...(remoteIngressEnabled
|
||||
? {
|
||||
remoteIngress: {
|
||||
enabled: true,
|
||||
...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(priority != null && !isNaN(priority) ? { priority } : {}),
|
||||
};
|
||||
|
||||
@@ -641,13 +781,9 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
// Build metadata if profile/target selected
|
||||
const metadata: any = {};
|
||||
const profileRefValue = formData.sourceProfileRef as any;
|
||||
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
|
||||
if (profileKey) {
|
||||
metadata.sourceProfileRef = profileKey;
|
||||
}
|
||||
const targetRefValue = formData.networkTargetRef as any;
|
||||
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
}
|
||||
@@ -669,6 +805,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
if (createForm) {
|
||||
await createForm.updateComplete;
|
||||
setupTlsVisibility(createForm);
|
||||
setupTargetInputState(createForm);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,19 +49,28 @@ export class OpsViewVpn extends DeesElement {
|
||||
@state()
|
||||
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
|
||||
|
||||
@state()
|
||||
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
|
||||
this.vpnState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
const targetProfilesSub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
|
||||
this.targetProfilesState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(targetProfilesSub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
|
||||
// Ensure target profiles are loaded for autocomplete candidates
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
|
||||
await Promise.all([
|
||||
appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null),
|
||||
appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null),
|
||||
]);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -330,13 +339,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
'Status': statusHtml,
|
||||
'Routing': routingHtml,
|
||||
'VPN IP': client.assignedIp || '-',
|
||||
'Target Profiles': client.targetProfileIds?.length
|
||||
? html`${client.targetProfileIds.map(id => {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profile = profileState?.profiles.find(p => p.id === id);
|
||||
return html`<span class="tagBadge">${profile?.name || id}</span>`;
|
||||
})}`
|
||||
: '-',
|
||||
'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
|
||||
'Description': client.description || '-',
|
||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||
};
|
||||
@@ -347,6 +350,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
await this.ensureTargetProfilesLoaded();
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const profileCandidates = this.getTargetProfileCandidates();
|
||||
const createModal = await DeesModal.createAndShow({
|
||||
@@ -647,6 +651,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
await this.ensureTargetProfilesLoaded();
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const currentDescription = client.description ?? '';
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
|
||||
@@ -810,12 +815,28 @@ export class OpsViewVpn extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async ensureTargetProfilesLoaded(): Promise<void> {
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
|
||||
}
|
||||
|
||||
private renderTargetProfileBadges(ids?: string[]): TemplateResult | string {
|
||||
const labels = this.resolveProfileIdsToLabels(ids, {
|
||||
pendingLabel: 'Loading profile...',
|
||||
missingLabel: (id) => `Unknown profile (${id})`,
|
||||
});
|
||||
|
||||
if (!labels?.length) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return html`${labels.map((label) => html`<span class="tagBadge">${label}</span>`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build stable profile labels for list inputs.
|
||||
*/
|
||||
private getTargetProfileChoices() {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
const profiles = this.targetProfilesState.profiles || [];
|
||||
const nameCounts = new Map<string, number>();
|
||||
|
||||
for (const profile of profiles) {
|
||||
@@ -837,12 +858,27 @@ export class OpsViewVpn extends DeesElement {
|
||||
/**
|
||||
* Convert profile IDs to form labels (for populating edit form values).
|
||||
*/
|
||||
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
|
||||
private resolveProfileIdsToLabels(
|
||||
ids?: string[],
|
||||
options: {
|
||||
pendingLabel?: string;
|
||||
missingLabel?: (id: string) => string;
|
||||
} = {},
|
||||
): string[] | undefined {
|
||||
if (!ids?.length) return undefined;
|
||||
const choices = this.getTargetProfileChoices();
|
||||
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
|
||||
return ids.map((id) => {
|
||||
return labelsById.get(id) || id;
|
||||
const label = labelsById.get(id);
|
||||
if (label) {
|
||||
return label;
|
||||
}
|
||||
|
||||
if (this.targetProfilesState.lastUpdated === 0 && !this.targetProfilesState.error) {
|
||||
return options.pendingLabel || 'Loading profile...';
|
||||
}
|
||||
|
||||
return options.missingLabel?.(id) || id;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
@@ -21,18 +22,23 @@ declare global {
|
||||
@customElement('ops-view-security-blocked')
|
||||
export class OpsViewSecurityBlocked extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
|
||||
accessor securityPolicyState: appstate.ISecurityPolicyState = appstate.securityPolicyStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.statsStatePart
|
||||
const sub = appstate.securityPolicyStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.statsState = s;
|
||||
this.securityPolicyState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.fetchSecurityPolicyAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
@@ -40,79 +46,436 @@ export class OpsViewSecurityBlocked extends DeesElement {
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.sectionStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statusBadge.enabled {
|
||||
background: ${cssManager.bdTheme('#e8f5e9', '#1a3a1a')};
|
||||
color: ${cssManager.bdTheme('#388e3c', '#66bb6a')};
|
||||
}
|
||||
|
||||
.statusBadge.disabled {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
|
||||
color: ${cssManager.bdTheme('#757575', '#999')};
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#eef2ff', '#1e1b4b')};
|
||||
color: ${cssManager.bdTheme('#4338ca', '#a5b4fc')};
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#b91c1c', '#fca5a5')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const blockedIPs: string[] = metrics.blockedIPs || [];
|
||||
const state = this.securityPolicyState;
|
||||
const activeRules = state.rules.filter((rule) => rule.enabled);
|
||||
const disabledRules = state.rules.length - activeRules.length;
|
||||
const compiledPolicy = state.compiledPolicy || { blockedIps: [], blockedCidrs: [] };
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalBlocked',
|
||||
title: 'Blocked IPs',
|
||||
value: blockedIPs.length,
|
||||
id: 'activeRules',
|
||||
title: 'Active Rules',
|
||||
value: activeRules.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldBan',
|
||||
color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Currently blocked addresses',
|
||||
icon: 'lucide:shield-check',
|
||||
color: activeRules.length > 0 ? '#ef4444' : '#22c55e',
|
||||
description: `${disabledRules} disabled`,
|
||||
},
|
||||
{
|
||||
id: 'compiledIps',
|
||||
title: 'Compiled IPs',
|
||||
value: compiledPolicy.blockedIps.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:server-off',
|
||||
color: '#ef4444',
|
||||
description: 'Direct IP blocks enforced by SmartProxy',
|
||||
},
|
||||
{
|
||||
id: 'compiledCidrs',
|
||||
title: 'Compiled CIDRs',
|
||||
value: compiledPolicy.blockedCidrs.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:network',
|
||||
color: '#f97316',
|
||||
description: 'Network ranges pushed to enforcement layers',
|
||||
},
|
||||
{
|
||||
id: 'intelligenceRecords',
|
||||
title: 'IP Intelligence',
|
||||
value: state.ipIntelligence.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:radar',
|
||||
color: '#6366f1',
|
||||
description: 'Observed public IPs with enrichment',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">Blocked IPs</dees-heading>
|
||||
<dees-heading level="3">Security Blocking</dees-heading>
|
||||
|
||||
${state.error ? html`<div class="errorMessage">${state.error}</div>` : html``}
|
||||
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<div class="sectionStack">
|
||||
${this.renderRulesTable()}
|
||||
${this.renderCompiledPolicyTable()}
|
||||
${this.renderIpIntelligenceTable()}
|
||||
${this.renderAuditTable()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRulesTable(): TemplateResult {
|
||||
return html`
|
||||
<dees-table
|
||||
.heading1=${'Blocked IP Addresses'}
|
||||
.heading2=${'IPs blocked due to suspicious activity'}
|
||||
.data=${blockedIPs.map((ip) => ({ ip }))}
|
||||
.displayFunction=${(item) => ({
|
||||
'IP Address': item.ip,
|
||||
'Reason': 'Suspicious activity',
|
||||
.heading1=${'Managed Block Rules'}
|
||||
.heading2=${'Rules compiled into SmartProxy policy and remote ingress edge firewall snapshots'}
|
||||
.data=${this.securityPolicyState.rules}
|
||||
.rowKey=${'id'}
|
||||
.displayFunction=${(rule: interfaces.data.ISecurityBlockRule) => ({
|
||||
'Type': html`<span class="typeBadge">${rule.type}</span>`,
|
||||
'Value': rule.value,
|
||||
'Match': rule.type === 'organization' ? (rule.matchMode || 'contains') : '-',
|
||||
'Reason': rule.reason || '-',
|
||||
'Status': html`<span class="statusBadge ${rule.enabled ? 'enabled' : 'disabled'}">${rule.enabled ? 'Enabled' : 'Disabled'}</span>`,
|
||||
'Created': this.formatDateTime(rule.createdAt),
|
||||
'Updated': this.formatDateTime(rule.updatedAt),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Unblock',
|
||||
iconName: 'lucide:shield-off',
|
||||
type: ['contextmenu' as const],
|
||||
actionFunc: async (item) => {
|
||||
await this.unblockIP(item.ip);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clear All',
|
||||
iconName: 'lucide:trash-2',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.clearBlockedIPs();
|
||||
},
|
||||
},
|
||||
]}
|
||||
.dataActions=${this.getRuleActions()}
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
dataName="rule"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private async clearBlockedIPs() {
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert('Clearing blocked IPs is not yet supported from the UI.');
|
||||
private renderCompiledPolicyTable(): TemplateResult {
|
||||
const policy = this.securityPolicyState.compiledPolicy || { blockedIps: [], blockedCidrs: [] };
|
||||
const rows = [
|
||||
...policy.blockedIps.map((value) => ({ type: 'ip', value })),
|
||||
...policy.blockedCidrs.map((value) => ({ type: 'cidr', value })),
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.heading1=${'Compiled Enforcement Policy'}
|
||||
.heading2=${'Concrete IPs and CIDRs currently sent to SmartProxy and remote ingress'}
|
||||
.data=${rows}
|
||||
.rowKey=${'value'}
|
||||
.displayFunction=${(row: { type: string; value: string }) => ({
|
||||
'Enforcement Type': html`<span class="typeBadge">${row.type}</span>`,
|
||||
'Value': row.value,
|
||||
})}
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
dataName="compiled rule"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private async unblockIP(ip: string) {
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
|
||||
private renderIpIntelligenceTable(): TemplateResult {
|
||||
return html`
|
||||
<dees-table
|
||||
.heading1=${'Observed IP Intelligence'}
|
||||
.heading2=${'Public IPs observed in network metrics and enriched for ASN / organization matching'}
|
||||
.data=${this.securityPolicyState.ipIntelligence}
|
||||
.rowKey=${'ipAddress'}
|
||||
.displayFunction=${(record: interfaces.data.IIpIntelligenceRecord) => ({
|
||||
'IP Address': record.ipAddress,
|
||||
'ASN': record.asn ? `AS${record.asn}` : '-',
|
||||
'ASN Org': record.asnOrg || '-',
|
||||
'Registrant Org': record.registrantOrg || '-',
|
||||
'Country': record.countryCode || record.country || '-',
|
||||
'Network Range': record.networkRange || '-',
|
||||
'Abuse Contact': record.abuseContact || '-',
|
||||
'Seen': record.seenCount,
|
||||
'Last Seen': this.formatDateTime(record.lastSeenAt),
|
||||
})}
|
||||
.dataActions=${this.getIpIntelligenceActions()}
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
dataName="ip intelligence record"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAuditTable(): TemplateResult {
|
||||
return html`
|
||||
<dees-table
|
||||
.heading1=${'Policy Audit'}
|
||||
.heading2=${'Recent security policy changes'}
|
||||
.data=${this.securityPolicyState.auditEvents}
|
||||
.rowKey=${'id'}
|
||||
.displayFunction=${(event: interfaces.data.ISecurityPolicyAuditEvent) => ({
|
||||
'Time': this.formatDateTime(event.createdAt),
|
||||
'Action': event.action,
|
||||
'Actor': event.actor,
|
||||
'Details': this.formatAuditDetails(event.details),
|
||||
})}
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
dataName="audit event"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private getRuleActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Create Rule',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => this.showRuleDialog(),
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => this.showRuleDialog(actionData.item),
|
||||
},
|
||||
{
|
||||
name: 'Enable',
|
||||
iconName: 'lucide:play',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const rule = actionData.item as interfaces.data.ISecurityBlockRule;
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, {
|
||||
id: rule.id,
|
||||
enabled: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Disable',
|
||||
iconName: 'lucide:pause',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const rule = actionData.item as interfaces.data.ISecurityBlockRule;
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, {
|
||||
id: rule.id,
|
||||
enabled: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
type: ['contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const rule = actionData.item as interfaces.data.ISecurityBlockRule;
|
||||
if (!window.confirm(`Delete block rule ${rule.type}:${rule.value}?`)) return;
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.deleteSecurityBlockRuleAction, rule.id);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private getIpIntelligenceActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Refresh Intelligence',
|
||||
iconName: 'lucide:refresh-cw',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.refreshIpIntelligenceAction, record.ipAddress);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block IP',
|
||||
iconName: 'lucide:shield-ban',
|
||||
type: ['contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
|
||||
await this.showRuleDialog(undefined, {
|
||||
type: 'ip',
|
||||
value: record.ipAddress,
|
||||
reason: 'Blocked from IP intelligence table',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block Network Range',
|
||||
iconName: 'lucide:network',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.networkRange),
|
||||
actionFunc: async (actionData: any) => {
|
||||
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
|
||||
await this.showRuleDialog(undefined, {
|
||||
type: 'cidr',
|
||||
value: record.networkRange || '',
|
||||
reason: 'Blocked network range from IP intelligence table',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block ASN',
|
||||
iconName: 'lucide:radio-tower',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.asn),
|
||||
actionFunc: async (actionData: any) => {
|
||||
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
|
||||
await this.showRuleDialog(undefined, {
|
||||
type: 'asn',
|
||||
value: String(record.asn),
|
||||
reason: 'Blocked ASN from IP intelligence table',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block Organization',
|
||||
iconName: 'lucide:building-2',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.asnOrg || actionData.item.registrantOrg),
|
||||
actionFunc: async (actionData: any) => {
|
||||
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
|
||||
await this.showRuleDialog(undefined, {
|
||||
type: 'organization',
|
||||
value: record.asnOrg || record.registrantOrg || '',
|
||||
reason: 'Blocked organization from IP intelligence table',
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async showRuleDialog(
|
||||
rule?: interfaces.data.ISecurityBlockRule,
|
||||
defaults: Partial<interfaces.data.ISecurityBlockRule> = {},
|
||||
): Promise<void> {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const typeOptions = [
|
||||
{ key: 'ip', option: 'IP address' },
|
||||
{ key: 'cidr', option: 'CIDR / network range' },
|
||||
{ key: 'asn', option: 'ASN' },
|
||||
{ key: 'organization', option: 'Organization' },
|
||||
];
|
||||
const matchModeOptions = [
|
||||
{ key: 'contains', option: 'Organization contains value' },
|
||||
{ key: 'exact', option: 'Organization exactly matches value' },
|
||||
];
|
||||
const selectedType = rule?.type || defaults.type || 'ip';
|
||||
const selectedMatchMode = rule?.matchMode || defaults.matchMode || 'contains';
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: rule ? `Edit Block Rule: ${rule.type}:${rule.value}` : 'Create Block Rule',
|
||||
content: html`
|
||||
<dees-form>
|
||||
${rule ? html`` : html`
|
||||
<dees-input-dropdown
|
||||
.key=${'type'}
|
||||
.label=${'Rule Type'}
|
||||
.options=${typeOptions}
|
||||
.selectedOption=${typeOptions.find((option) => option.key === selectedType)}
|
||||
></dees-input-dropdown>
|
||||
`}
|
||||
<dees-input-text
|
||||
.key=${'value'}
|
||||
.label=${'Value'}
|
||||
.value=${rule?.value || defaults.value || ''}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.key=${'matchMode'}
|
||||
.label=${'Organization Match Mode'}
|
||||
.description=${'Only used for organization rules'}
|
||||
.options=${matchModeOptions}
|
||||
.selectedOption=${matchModeOptions.find((option) => option.key === selectedMatchMode)}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text
|
||||
.key=${'reason'}
|
||||
.label=${'Reason'}
|
||||
.value=${rule?.reason || defaults.reason || ''}
|
||||
></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.key=${'enabled'}
|
||||
.label=${'Enabled'}
|
||||
.value=${rule ? rule.enabled : defaults.enabled !== false}
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: rule ? 'Save' : 'Create',
|
||||
iconName: rule ? 'lucide:check' : 'lucide:plus',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const type = (rule?.type || this.getDropdownKey(data.type)) as interfaces.data.TSecurityBlockRuleType;
|
||||
const value = String(data.value || '').trim();
|
||||
if (!type || !value) return;
|
||||
const matchMode = type === 'organization'
|
||||
? this.getDropdownKey(data.matchMode) as interfaces.data.TSecurityBlockRuleMatchMode
|
||||
: undefined;
|
||||
const payload = {
|
||||
value,
|
||||
matchMode,
|
||||
reason: String(data.reason || '').trim() || undefined,
|
||||
enabled: data.enabled !== false,
|
||||
};
|
||||
if (rule) {
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, {
|
||||
id: rule.id,
|
||||
...payload,
|
||||
});
|
||||
} else {
|
||||
await appstate.securityPolicyStatePart.dispatchAction(appstate.createSecurityBlockRuleAction, {
|
||||
type,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private getDropdownKey(value: any): string {
|
||||
return typeof value === 'string' ? value : value?.key || '';
|
||||
}
|
||||
|
||||
private formatDateTime(timestamp?: number): string {
|
||||
return timestamp ? new Date(timestamp).toLocaleString() : '-';
|
||||
}
|
||||
|
||||
private formatAuditDetails(details: Record<string, unknown>): string {
|
||||
const text = JSON.stringify(details);
|
||||
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
||||
}
|
||||
}
|
||||
|
||||
+44
-47
@@ -1,72 +1,69 @@
|
||||
# @serve.zone/dcrouter-web
|
||||
|
||||
Browser UI package for dcrouter's operations dashboard. 🖥️
|
||||
|
||||
This package contains the browser entrypoint, app state, router, and web components that power the Ops dashboard served by dcrouter.
|
||||
`@serve.zone/dcrouter-web` is the browser-side Ops dashboard module for dcrouter. It provides the SPA entry point, route synchronization, app state, and web-component views that OpsServer serves from the main dcrouter runtime.
|
||||
|
||||
## 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.
|
||||
|
||||
## What Is In Here
|
||||
## What It Boots
|
||||
|
||||
| Path | Purpose |
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `index.ts` | Browser entrypoint that initializes routing and renders `<ops-dashboard>` |
|
||||
| `appstate.ts` | Central reactive state and action definitions |
|
||||
| `router.ts` | URL-based dashboard routing |
|
||||
| `elements/` | Dashboard views and reusable UI pieces |
|
||||
| `index.ts` | Initializes the app router and renders `<ops-dashboard>` into `document.body`. |
|
||||
| `router.ts` | Defines top-level dashboard routes, subviews, redirects, and URL/state synchronization. |
|
||||
| `appstate.ts` | Holds reactive login, UI, config, stats, route, DNS, email, remote ingress, VPN, and log state. |
|
||||
| `elements/` | Contains the dashboard shell and feature-specific Dees web components. |
|
||||
|
||||
## Main Views
|
||||
## View Map
|
||||
|
||||
The dashboard currently includes views for:
|
||||
| Top-level view | Subviews |
|
||||
| --- | --- |
|
||||
| `overview` | `stats`, `configuration` |
|
||||
| `network` | `activity`, `routes`, `sourceprofiles`, `networktargets`, `targetprofiles`, `remoteingress`, `vpn` |
|
||||
| `email` | `log`, `security`, `domains` |
|
||||
| `logs` | flat view |
|
||||
| `access` | `apitokens`, `users` |
|
||||
| `security` | `overview`, `blocked`, `authentication` |
|
||||
| `domains` | `providers`, `domains`, `dns`, `certificates` |
|
||||
|
||||
- overview and configuration
|
||||
- network activity and route management
|
||||
- source profiles, target profiles, and network targets
|
||||
- email activity and email domains
|
||||
- DNS providers, domains, DNS records, and certificates
|
||||
- API tokens and users
|
||||
- VPN, remote ingress, logs, and security views
|
||||
## Runtime Communication
|
||||
|
||||
## Route Management UX
|
||||
The dashboard talks to the dcrouter OpsServer through:
|
||||
|
||||
The web UI reflects dcrouter's current route ownership model:
|
||||
- TypedRequest calls for normal API actions
|
||||
- shared contracts from `@serve.zone/dcrouter-interfaces`
|
||||
- TypedSocket log streaming for live operational output
|
||||
- Dees web components and app-state subscriptions for UI updates
|
||||
- QR code rendering for VPN client UX
|
||||
|
||||
- system routes are shown separately from user routes
|
||||
- system routes are visible and toggleable
|
||||
- system routes are not directly editable or deletable
|
||||
- API routes are fully managed through the route-management forms
|
||||
## Usage
|
||||
|
||||
## How It Talks To dcrouter
|
||||
|
||||
The frontend uses TypedRequest and shared interfaces from `@serve.zone/dcrouter-interfaces`.
|
||||
|
||||
State actions in `appstate.ts` fetch and mutate:
|
||||
|
||||
- stats and health
|
||||
- logs
|
||||
- routes and tokens
|
||||
- certificates and ACME config
|
||||
- DNS providers, domains, and records
|
||||
- email domains and email operations
|
||||
- VPN, remote ingress, and RADIUS data
|
||||
|
||||
## Development Notes
|
||||
|
||||
The browser bundle is built from this package and served by the main dcrouter package.
|
||||
This package is primarily consumed by the main dcrouter build and served by OpsServer. Install it directly only when you intentionally need the dashboard module boundary.
|
||||
|
||||
```bash
|
||||
pnpm run bundle
|
||||
pnpm add @serve.zone/dcrouter-web
|
||||
```
|
||||
|
||||
For the full server and hosted dashboard, use `@serve.zone/dcrouter`.
|
||||
|
||||
## Development
|
||||
|
||||
This folder is published from the dcrouter monorepo via `tspublish.json` with order `4`.
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm run watch
|
||||
```
|
||||
|
||||
The generated bundle is written into `dist_serve/` by the main build pipeline.
|
||||
The dcrouter build emits served dashboard assets into `dist_serve/`.
|
||||
|
||||
## When To Use This Package
|
||||
Useful source entry points:
|
||||
|
||||
- Use it if you want the dashboard frontend as a package/module boundary.
|
||||
- Use the main `@serve.zone/dcrouter` package if you want the server that actually serves this UI.
|
||||
- `index.ts` boots the frontend.
|
||||
- `router.ts` owns URL/view state synchronization.
|
||||
- `elements/ops-dashboard.ts` defines the app shell and tab map.
|
||||
- `elements/network/`, `elements/domains/`, `elements/email/`, `elements/security/`, `elements/access/`, and `elements/overview/` hold feature views.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -82,7 +79,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.
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user