Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b928b038e | |||
| a466b88408 | |||
| e26ea9e114 | |||
| c5ca95b6f5 | |||
| 1f25ca4095 | |||
| 2891e5d3ee | |||
| 152110c877 | |||
| d780e02928 |
30
changelog.md
30
changelog.md
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.19.0",
|
||||
"version": "13.20.2",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
276
readme.md
276
readme.md
@@ -2,33 +2,31 @@
|
||||
|
||||

|
||||
|
||||
`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.
|
||||
`dcrouter` is a TypeScript control plane for running a serious multi-protocol edge or datacenter gateway from one process. It wires together SmartProxy for HTTP/HTTPS/TCP routing, smartmta for email, smartdns for authoritative DNS and DNS-over-HTTPS, smartradius, smartvpn, remote ingress tunnels, a TypedRequest API, and the Ops dashboard.
|
||||
|
||||
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.
|
||||
Use it when you want one place to define routes, manage domains and certificates, protect internal services, automate changes over an API, and operate the whole stack from a browser.
|
||||
|
||||
## 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 Works
|
||||
|
||||
- 🌐 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.
|
||||
- 🌐 One runtime for HTTP/HTTPS/TCP, SMTP, authoritative DNS + DoH, RADIUS, VPN, and remote ingress.
|
||||
- 🧠 Constructor config becomes system-managed routes, while API-created routes stay editable and clearly separated.
|
||||
- 🔐 Certificates, DNS providers, domains, records, API tokens, access profiles, and protected routes live in one management plane.
|
||||
- 🖥️ The OpsServer UI and TypedRequest API are first-class parts of the package, not an afterthought.
|
||||
- ⚡ Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default unless you opt out.
|
||||
|
||||
## What It Covers
|
||||
## What You Get
|
||||
|
||||
| Area | What dcrouter does |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| HTTP / HTTPS / TCP | SmartProxy-based reverse proxying, TLS termination or passthrough, path/domain/port matching, TCP/SNI forwarding |
|
||||
| Email | SMTP ingress and delivery via `UnifiedEmailServer`, route-based mail actions, email-domain management, queue and resend operations |
|
||||
| DNS | Authoritative zones, nameserver bootstrap records, DNS-over-HTTPS routes on `/dns-query` and `/resolve`, provider-backed domain management |
|
||||
| Access and Edge | VPN-gated routes, RADIUS auth/accounting, remote ingress edge registrations and tunnel hub support |
|
||||
| Operations | Browser dashboard, TypedRequest API, route management, tokens, certificates, logs, metrics, and health views |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -38,7 +36,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 example stays on unprivileged ports so you can run it locally without root.
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
@@ -47,10 +45,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,114 +66,61 @@ 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 credentials `admin` / `admin`
|
||||
- send proxied traffic to `http://localhost:18080`
|
||||
- stop gracefully with `await router.stop()`
|
||||
|
||||
## Mental Model
|
||||
## Route Ownership 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.
|
||||
dcrouter keeps route ownership explicit so automation does not accidentally stomp on system-generated traffic.
|
||||
|
||||
| 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 |
|
||||
| Route origin | Where it comes from | What you can do |
|
||||
| --- | --- | --- |
|
||||
| `config` | Constructor `smartProxyConfig.routes` and related seed config | View and toggle |
|
||||
| `email` | Email listener and mail-routing derived routes | View and toggle |
|
||||
| `dns` | Generated DoH and DNS-related routes | View and toggle |
|
||||
| `api` | Created through the Ops UI or API client | Full CRUD |
|
||||
|
||||
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
|
||||
- system routes are persisted with stable `systemKey` values
|
||||
- DNS-over-HTTPS routes are persisted and then hydrated with live socket handlers at runtime
|
||||
- editing and deletion are reserved for `api` routes; system routes are toggle-only by design
|
||||
|
||||
## Core Features
|
||||
## Configuration Cheat Sheet
|
||||
|
||||
### 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`.
|
||||
The main entrypoint is `IDcRouterOptions`.
|
||||
|
||||
| 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 |
|
||||
| `smartProxyConfig` | Main HTTP/HTTPS and TCP/SNI routing config |
|
||||
| `emailConfig` | Email hostname, ports, domains, and mail routing rules |
|
||||
| `emailPortConfig` | External-to-internal email port remapping and received-email storage path |
|
||||
| `tls` | ACME contact and static certificate paths |
|
||||
| `dnsNsDomains` | Nameserver hostnames used for NS bootstrap and DoH route generation |
|
||||
| `dnsScopes` | Domains served authoritatively by the embedded DNS server |
|
||||
| `dnsRecords` | Static constructor-defined DNS records |
|
||||
| `publicIp` / `proxyIps` | How A records are exposed for nameserver and service records |
|
||||
| `dbConfig` | Embedded LocalSmartDb or external MongoDB-backed persistence |
|
||||
| `radiusConfig` | RADIUS auth, VLAN assignment, and accounting |
|
||||
| `remoteIngressConfig` | Remote ingress hub and edge tunnel setup |
|
||||
| `vpnConfig` | VPN server and client definitions for protected route access |
|
||||
| `http3` | Global HTTP/3 behavior for qualifying HTTPS routes |
|
||||
| `opsServerPort` | Ops dashboard and TypedRequest API port |
|
||||
|
||||
## Example: Enabling DNS, Email, and VPN
|
||||
## Important Behavior
|
||||
|
||||
- `dbConfig.enabled` defaults to `true`. If you do not provide `mongoDbUrl`, dcrouter starts an embedded local database automatically.
|
||||
- If you disable the DB, constructor-driven traffic can still run, but DB-backed features such as persistent routes, tokens, ACME config, and managed domains do not start.
|
||||
- Qualifying HTTPS forward routes on port `443` get HTTP/3 by default unless `http3.enabled === false` or the route opts out.
|
||||
- DNS-over-HTTPS endpoints are generated on the first entry of `dnsNsDomains` at `/dns-query` and `/resolve`.
|
||||
- Email listener ports are internally remapped by default, so common external ports such as `25`, `587`, and `465` end up on internal ports like `10025`, `10587`, and `10465`.
|
||||
- Provider-backed domains can be managed in the Ops plane without being served by the embedded authoritative DNS server.
|
||||
|
||||
## Bigger Example
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
@@ -195,6 +140,19 @@ const router = new DcRouter({
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'internal-admin',
|
||||
match: {
|
||||
domains: ['internal.example.com'],
|
||||
ports: [443],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 9090 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
vpnOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
emailConfig: {
|
||||
@@ -233,93 +191,103 @@ const router = new DcRouter({
|
||||
dbConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
opsServerPort: 3000,
|
||||
});
|
||||
|
||||
await router.start();
|
||||
```
|
||||
|
||||
## Operations API and Dashboard
|
||||
## Automation
|
||||
|
||||
With the database enabled, dcrouter exposes a management plane for:
|
||||
dcrouter gives you three good integration layers:
|
||||
|
||||
- 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 served by `OpsServer`
|
||||
- raw TypedRequest contracts via `@serve.zone/dcrouter/interfaces`
|
||||
- a higher-level OO API client via `@serve.zone/dcrouter/apiclient` or `@serve.zone/dcrouter-apiclient`
|
||||
|
||||
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.
|
||||
### OO API Client Example
|
||||
|
||||
```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()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8081 }] })
|
||||
.save();
|
||||
|
||||
if (routes[0] && routes[0].origin !== 'api') {
|
||||
await routes[0].toggle(false);
|
||||
}
|
||||
```
|
||||
|
||||
See `./ts_apiclient/readme.md` for the dedicated API-client package docs.
|
||||
See `./ts_apiclient/readme.md` for the dedicated client package and `./ts_interfaces/readme.md` for the raw contracts.
|
||||
|
||||
## OCI / Container Bootstrap
|
||||
|
||||
The package also includes an environment-driven bootstrap used by `runCli()` when `DCROUTER_MODE=OCI_CONTAINER`.
|
||||
|
||||
```typescript
|
||||
import { runCli } from '@serve.zone/dcrouter';
|
||||
|
||||
await runCli();
|
||||
```
|
||||
|
||||
Useful environment variables include:
|
||||
|
||||
- `DCROUTER_CONFIG_PATH`
|
||||
- `DCROUTER_BASE_DIR`
|
||||
- `DCROUTER_TLS_EMAIL`
|
||||
- `DCROUTER_TLS_DOMAIN`
|
||||
- `DCROUTER_PUBLIC_IP`
|
||||
- `DCROUTER_PROXY_IPS`
|
||||
- `DCROUTER_DNS_NS_DOMAINS`
|
||||
- `DCROUTER_DNS_SCOPES`
|
||||
- `DCROUTER_EMAIL_HOSTNAME`
|
||||
- `DCROUTER_EMAIL_PORTS`
|
||||
|
||||
## Published Modules
|
||||
|
||||
This repository publishes multiple modules from the same codebase.
|
||||
This repository ships several 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 request and data contracts as a subpath export | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter/apiclient` | OO API client as a subpath export | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-interfaces` | Standalone interfaces package | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter-apiclient` | Standalone OO API client package | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-migrations` | Standalone migration runner package | `./ts_migrations/readme.md` |
|
||||
| `@serve.zone/dcrouter-web` | Standalone web dashboard package boundary | `./ts_web/readme.md` |
|
||||
|
||||
## Development and Testing
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Target a single test file while working on one area:
|
||||
Target a single test while working on one area:
|
||||
|
||||
```bash
|
||||
tstest test/test.dns-runtime-routes.node.ts --verbose
|
||||
tstest test/test.apiclient.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.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -1,6 +1,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();
|
||||
|
||||
110
test/test.vpn-runtime.node.ts
Normal file
110
test/test.vpn-runtime.node.ts
Normal file
@@ -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()
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.19.0',
|
||||
version: '13.20.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
@@ -565,20 +566,7 @@ 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
|
||||
@@ -2292,6 +2280,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 +2455,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
|
||||
|
||||
@@ -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();
|
||||
@@ -368,7 +386,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 +422,46 @@ 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,
|
||||
};
|
||||
|
||||
if (!normalized.sourceProfileRef) {
|
||||
normalized.sourceProfileName = undefined;
|
||||
}
|
||||
if (!normalized.networkTargetRef) {
|
||||
normalized.networkTargetName = undefined;
|
||||
}
|
||||
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
||||
normalized.lastResolvedAt = undefined;
|
||||
}
|
||||
|
||||
if (Object.values(normalized).every((value) => value === undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: warnings
|
||||
// =========================================================================
|
||||
@@ -446,7 +504,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);
|
||||
}
|
||||
|
||||
149
ts/readme.md
149
ts/readme.md
@@ -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,120 +33,53 @@ 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
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
@@ -148,7 +91,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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# @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.
|
||||
Typed, object-oriented client for operating a running dcrouter instance. It wraps the OpsServer `/typedrequest` API in managers and resource classes so your scripts can work with routes, certificates, tokens, remote ingress edges, emails, stats, config, logs, and RADIUS without hand-rolling requests.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -14,7 +12,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
```
|
||||
|
||||
Or import through the main package:
|
||||
You can also import the same client through the main package subpath:
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
@@ -29,24 +27,40 @@ 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('route count', routes.length, 'warnings', 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(false);
|
||||
```
|
||||
|
||||
## What the Client Gives You
|
||||
|
||||
| Manager | Purpose |
|
||||
| --- | --- |
|
||||
| `client.routes` | List merged routes, create API routes, toggle routes |
|
||||
| `client.certificates` | Inspect certificates and run certificate operations |
|
||||
| `client.apiTokens` | Create, list, toggle, roll, and revoke API tokens |
|
||||
| `client.remoteIngress` | Manage edge registrations, statuses, and connection tokens |
|
||||
| `client.emails` | Inspect email items and trigger resend flows |
|
||||
| `client.stats` | Health, statistics, and operational summaries |
|
||||
| `client.config` | Read the current configuration view |
|
||||
| `client.logs` | Read recent logs and log-related data |
|
||||
| `client.radius` | Manage RADIUS clients, VLANs, and sessions |
|
||||
|
||||
## Authentication Modes
|
||||
|
||||
| Mode | How it works |
|
||||
| --- | --- |
|
||||
| Admin login | Call `login(username, password)` and the client stores the returned identity for later requests |
|
||||
| API token | Pass `apiToken` into the constructor for token-based automation |
|
||||
| Admin login | Call `login(username, password)` and the returned identity is stored on the client |
|
||||
| API token | Pass `apiToken` in the constructor and it is injected into requests automatically |
|
||||
|
||||
```typescript
|
||||
const client = new DcRouterApiClient({
|
||||
@@ -55,52 +69,19 @@ const client = new DcRouterApiClient({
|
||||
});
|
||||
```
|
||||
|
||||
## Main Managers
|
||||
|
||||
| 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 |
|
||||
|
||||
## 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'`.
|
||||
- `baseUrl` is normalized, and the client automatically calls `${baseUrl}/typedrequest`
|
||||
- `buildRequestPayload()` injects the current identity and optional API token for you
|
||||
- system routes can be toggled, but only API routes are meant for edit and delete flows
|
||||
|
||||
## Route Builder Example
|
||||
|
||||
```typescript
|
||||
const { routes } = await client.routes.list();
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.origin !== 'api') {
|
||||
await route.toggle(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Builder Example
|
||||
|
||||
```typescript
|
||||
const route = await client.routes.build()
|
||||
const newRoute = await client.routes.build()
|
||||
.setName('internal-app')
|
||||
.setMatch({
|
||||
ports: 80,
|
||||
ports: 443,
|
||||
domains: ['internal.example.com'],
|
||||
})
|
||||
.setAction({
|
||||
@@ -110,30 +91,47 @@ const route = await client.routes.build()
|
||||
.setEnabled(true)
|
||||
.save();
|
||||
|
||||
await route.toggle(false);
|
||||
await newRoute.update({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 3001 }],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Example: Certificates and Stats
|
||||
## Token and Remote Ingress Example
|
||||
|
||||
```typescript
|
||||
const { certificates, summary } = await client.certificates.list();
|
||||
console.log(summary.valid, summary.failed);
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('ci-token')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.setExpiresInDays(30)
|
||||
.save();
|
||||
|
||||
const health = await client.stats.getHealth();
|
||||
const recentLogs = await client.logs.getRecent({ level: 'error', limit: 20 });
|
||||
console.log('copy this once:', token.tokenValue);
|
||||
|
||||
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(connectionToken);
|
||||
```
|
||||
|
||||
## What This Package Does Not Do
|
||||
|
||||
- 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 bundle the dashboard.
|
||||
- It does not replace the raw interfaces package when you want low-level 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` to run the server and `@serve.zone/dcrouter-interfaces` for the shared request/data types.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# @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.
|
||||
Shared TypeScript contracts for dcrouter's TypedRequest API. Use this package when you want compile-time request/response types and shared data models without pulling in the higher-level client SDK.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -14,7 +12,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
pnpm add @serve.zone/dcrouter-interfaces
|
||||
```
|
||||
|
||||
Or consume the same interfaces through the main package:
|
||||
You can also consume the same contracts through the main package subpath:
|
||||
|
||||
```typescript
|
||||
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
@@ -22,17 +20,28 @@ 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 data such as identities, routes, stats, domains, DNS records, VPN data, remote ingress data, and email-domain data |
|
||||
| `requests` | TypedRequest request/response contracts for OpsServer endpoints |
|
||||
| `typedrequestInterfaces` | Re-exported helper types from `@api.global/typedrequest-interfaces` |
|
||||
|
||||
## Example
|
||||
## API Surface Covered
|
||||
|
||||
| Domain | Examples |
|
||||
| --- | --- |
|
||||
| Auth | login, logout, identity verification |
|
||||
| Routes | list merged routes, create, update, delete, toggle |
|
||||
| Access | API tokens, source profiles, target profiles, network targets, users |
|
||||
| DNS and domains | providers, domains, DNS records, ACME config |
|
||||
| Email | email operations and email-domain management |
|
||||
| Edge services | remote ingress, VPN, RADIUS |
|
||||
| Observability | stats, health, logs, configuration |
|
||||
|
||||
## Quick 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 +50,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',
|
||||
);
|
||||
@@ -55,42 +65,17 @@ for (const route of response.routes) {
|
||||
}
|
||||
```
|
||||
|
||||
## API Domains Covered
|
||||
|
||||
| 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 |
|
||||
|
||||
## Notable Data Types
|
||||
|
||||
| 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 |
|
||||
|
||||
## When To Use This Package
|
||||
|
||||
- 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.
|
||||
- Use it in tests that need strong request/response typing.
|
||||
- Use it in custom CLIs or dashboards that call TypedRequest directly.
|
||||
- Use it in shared code where both client and server need the same data shapes.
|
||||
|
||||
If you want a higher-level client with managers and resource classes, use `@serve.zone/dcrouter-apiclient` instead.
|
||||
If you want managers, builders, and resource classes instead of raw contracts, use `@serve.zone/dcrouter-apiclient`.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -1,53 +1,63 @@
|
||||
# @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.
|
||||
Versioned SmartMigration chain for dcrouter's persistent data. This package builds the migration runner that dcrouter executes before DB-backed managers start reading 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.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-migrations
|
||||
```
|
||||
|
||||
## What It Exports
|
||||
|
||||
| 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 |
|
||||
| `createMigrationRunner(db, targetVersion)` | Builds the dcrouter migration runner for the target application version |
|
||||
| `IMigrationRunner` | Small interface for the returned runner |
|
||||
| `IMigrationRunResult` | Result shape logged after a run |
|
||||
|
||||
## When To Use It
|
||||
|
||||
- You are embedding dcrouter's storage layer outside the full runtime.
|
||||
- You want to test or inspect schema transitions directly.
|
||||
- You are extending dcrouter with new persistent data and need versioned upgrades.
|
||||
|
||||
If you boot the full `DcRouter` runtime, this package is already used for you during startup.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
|
||||
|
||||
const migration = await createMigrationRunner(db, '13.18.0');
|
||||
const migration = await createMigrationRunner(db, '13.20.0');
|
||||
const result = await migration.run();
|
||||
|
||||
console.log(result.currentVersionBefore, result.currentVersionAfter);
|
||||
```
|
||||
|
||||
## What These Migrations Handle
|
||||
## What the Current Chain Covers
|
||||
|
||||
The migration chain currently covers dcrouter-specific storage transitions such as:
|
||||
- target profile target field migration from `host` to `ip`
|
||||
- legacy domain source rename from `manual` to `dcrouter`
|
||||
- legacy DNS record source rename from `manual` to `local`
|
||||
- route storage unification from `StoredRouteDoc` to `RouteDoc`
|
||||
- route `origin` backfill for migrated API routes
|
||||
- `systemKey` backfill for persisted config, email, and DNS routes
|
||||
|
||||
- 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`
|
||||
## Authoring Rules
|
||||
|
||||
## Important Behavior
|
||||
|
||||
- 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
|
||||
|
||||
If you are embedding dcrouter's DB layer outside the main runtime, run this package before any feature code assumes the latest schema.
|
||||
- Add new migration logic only in `ts_migrations/index.ts`.
|
||||
- Keep every step idempotent so reruns are safe.
|
||||
- Make each step's `.to()` version line up with the release version that ships it.
|
||||
- When adding new collection references, use the exact smartdata class-name collection casing for new code.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.19.0',
|
||||
version: '13.20.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -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,76 +1,56 @@
|
||||
# @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.
|
||||
Browser-side frontend for the dcrouter Ops dashboard. This folder is the SPA entrypoint, router, app state, and web-component UI rendered by OpsServer.
|
||||
|
||||
## 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 |
|
||||
- `index.ts` initializes the app router and renders `<ops-dashboard>` into `document.body`
|
||||
- `router.ts` defines top-level dashboard routes and subviews
|
||||
- `appstate.ts` holds reactive state, TypedRequest actions, and TypedSocket log streaming
|
||||
- `elements/` contains the dashboard shell and feature views
|
||||
|
||||
## View Map
|
||||
|
||||
| Top-level view | Subviews |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
|
||||
## Main Views
|
||||
|
||||
The dashboard currently includes views for:
|
||||
|
||||
- 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
|
||||
|
||||
## Route Management UX
|
||||
|
||||
The web UI reflects dcrouter's current route ownership model:
|
||||
|
||||
- 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
|
||||
| `overview` | `stats`, `configuration` |
|
||||
| `network` | `activity`, `routes`, `sourceprofiles`, `networktargets`, `targetprofiles`, `remoteingress`, `vpn` |
|
||||
| `email` | `log`, `security`, `domains` |
|
||||
| `access` | `apitokens`, `users` |
|
||||
| `security` | `overview`, `blocked`, `authentication` |
|
||||
| `domains` | `providers`, `domains`, `dns`, `certificates` |
|
||||
| `logs` | flat view |
|
||||
|
||||
## 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
|
||||
- TypedRequest for the main API surface
|
||||
- shared request and data contracts from `@serve.zone/dcrouter-interfaces`
|
||||
- TypedSocket for real-time log streaming
|
||||
- QR code generation for VPN client UX
|
||||
|
||||
## Development Notes
|
||||
|
||||
The browser bundle is built from this package and served by the main dcrouter package.
|
||||
This package is the frontend module boundary, but it is built and served as part of the main workspace.
|
||||
|
||||
```bash
|
||||
pnpm run bundle
|
||||
pnpm run build
|
||||
pnpm run watch
|
||||
```
|
||||
|
||||
The generated bundle is written into `dist_serve/` by the main build pipeline.
|
||||
The built dashboard assets are emitted into `dist_serve/` by the workspace build pipeline.
|
||||
|
||||
## When To Use This Package
|
||||
## What This Package Is For
|
||||
|
||||
- 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.
|
||||
- Use it when you want the dashboard frontend as its own published module boundary.
|
||||
- Use `@serve.zone/dcrouter` when you want the server that actually hosts this UI and the backend API.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user