Compare commits

..

8 Commits

Author SHA1 Message Date
d53cff6a94 v11.15.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 12:07:58 +00:00
eb211348d2 feat(vpn): add tag-based VPN route access control and support configured initial VPN clients 2026-03-30 12:07:58 +00:00
43618abeba v11.14.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:59:38 +00:00
dd9769b814 feat(docs): document VPN access control and add OpsServer VPN navigation 2026-03-30 08:59:38 +00:00
99b40fea3f v11.13.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:15:09 +00:00
6f72e4fdbc feat(vpn): add VPN server management and route-based VPN access control 2026-03-30 08:15:09 +00:00
fbe845cd8e v11.12.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 22:38:29 +00:00
31413d28be fix(acme): use X509 certificate expiry when reporting ACME certificate validity 2026-03-27 22:38:29 +00:00
26 changed files with 1849 additions and 54 deletions

View File

@@ -1,5 +1,36 @@
# Changelog
## 2026-03-30 - 11.15.0 - feat(vpn)
add tag-based VPN route access control and support configured initial VPN clients
- allow VPN-protected routes to restrict access to clients with matching server-defined tags instead of always permitting the full VPN subnet
- create configured VPN clients automatically on startup and re-apply routes when VPN clients change
- rename VPN client tag fields to serverDefinedClientTags across APIs, interfaces, handlers, and UI with legacy tag migration on load
- upgrade @push.rocks/smartvpn from 1.12.0 to 1.13.0
## 2026-03-30 - 11.14.0 - feat(docs)
document VPN access control and add OpsServer VPN navigation
- Adds comprehensive README documentation for VPN access control, configuration, operating modes, and client management
- Updates TypeScript interface documentation with VPN-related route, client, status, telemetry, and API request types
- Extends web dashboard documentation and router view list to include VPN management
## 2026-03-30 - 11.13.0 - feat(vpn)
add VPN server management and route-based VPN access control
- introduces a VPN manager backed by @push.rocks/smartvpn with persisted server keys and client registrations
- adds ops API handlers and typed request interfaces for VPN client lifecycle, status, config export, and telemetry
- adds ops dashboard VPN view and application state for managing VPN clients from the web UI
- supports vpn.required on routes by injecting VPN subnet allowlists into static and programmatic SmartProxy routes
- configures SmartProxy to accept proxy protocol in VPN socket forwarding mode to preserve client tunnel IPs
## 2026-03-27 - 11.12.4 - fix(acme)
use X509 certificate expiry when reporting ACME certificate validity
- Parse the actual X509 validTo value from the PEM public certificate and fall back to SmartAcme's stored expiry if parsing fails
- Update reported certificate expiry data and event communication timestamps to use the verified validity date
- Bump @push.rocks/smartacme to ^9.3.1 and @push.rocks/smartproxy to ^27.1.0
## 2026-03-27 - 11.12.3 - fix(dcrouter)
re-trigger auto certificate provisioning after SmartAcme becomes ready

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "11.12.3",
"version": "11.15.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -40,7 +40,7 @@
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.3.0",
"@push.rocks/smartacme": "^9.3.1",
"@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdb": "^2.0.0",
"@push.rocks/smartdns": "^7.9.0",
@@ -53,12 +53,13 @@
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.0.0",
"@push.rocks/smartproxy": "^27.1.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.13.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0",

75
pnpm-lock.yaml generated
View File

@@ -39,8 +39,8 @@ importers:
specifier: ^6.1.3
version: 6.1.3
'@push.rocks/smartacme':
specifier: ^9.3.0
version: 9.3.0(socks@2.8.7)
specifier: ^9.3.1
version: 9.3.1(socks@2.8.7)
'@push.rocks/smartdata':
specifier: ^7.1.3
version: 7.1.3(socks@2.8.7)
@@ -78,8 +78,8 @@ importers:
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartproxy':
specifier: ^27.0.0
version: 27.0.0
specifier: ^27.1.0
version: 27.1.0
'@push.rocks/smartradius':
specifier: ^1.1.1
version: 1.1.1
@@ -95,6 +95,9 @@ importers:
'@push.rocks/smartunique':
specifier: ^3.0.9
version: 3.0.9
'@push.rocks/smartvpn':
specifier: 1.13.0
version: 1.13.0
'@push.rocks/taskbuffer':
specifier: ^8.0.2
version: 8.0.2
@@ -1048,6 +1051,10 @@ packages:
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@peculiar/x509@2.0.0':
resolution: {integrity: sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==}
engines: {node: '>=20.0.0'}
'@pnpm/config.env-replace@1.1.0':
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
engines: {node: '>=12.22.0'}
@@ -1092,8 +1099,8 @@ packages:
'@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
'@push.rocks/smartacme@9.3.0':
resolution: {integrity: sha512-R6+fBNqlIy3fP2ECmOjBB65tl35w2+2vmSierO6oC9/5DW+khwjvFsT0+5WnfyjejEtWzdAprEseYWmBbyTGtA==}
'@push.rocks/smartacme@9.3.1':
resolution: {integrity: sha512-Cl1DVQ+rfpaYkk6VVm/KYVeUYzWfXzSfTXybHfCZ5SuiACuTVHZ6jK8TouELaV1RgrdYnIp0MrbiY2Kqi8ayAw==}
'@push.rocks/smartarchive@4.2.4':
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
@@ -1260,8 +1267,8 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@27.0.0':
resolution: {integrity: sha512-1scXCoXUM0Ify81une5LldTfbKaBFN8aa5xTiFg2PAS6R4QoGsYuj/aCmErVwBDzCF4G+je4Lh0wxLkMKy7QBA==}
'@push.rocks/smartproxy@27.1.0':
resolution: {integrity: sha512-uMtmbT6/9Y+lOnSi4w6SRICWJr9q9bHsYAq6xMLmym3zvnEzEwJWF6sw4Jb/uEFEjI2/e4irNSQ9Ba74DhFRlg==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1323,6 +1330,9 @@ packages:
'@push.rocks/smartversion@3.0.5':
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
'@push.rocks/smartvpn@1.13.0':
resolution: {integrity: sha512-oQY+GIvB9OZQMFEI/f4zwKwaUWPgG8Fsz8AGhPDedvH32jYNYEb9B957yRAROf7ndyQM/LThm7mN/5cx8ALyLw==}
'@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
engines: {node: '>=20.0.0'}
@@ -1339,9 +1349,6 @@ packages:
'@push.rocks/taskbuffer@3.5.0':
resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==}
'@push.rocks/taskbuffer@6.1.2':
resolution: {integrity: sha512-sdqKd8N/GidztQ1k3r8A86rLvD8Afyir5FjYCNJXDD9837JLoqzHaOKGltUSBsCGh2gjsZn6GydsY6HhXQgvZQ==}
'@push.rocks/taskbuffer@8.0.2':
resolution: {integrity: sha512-SRCAzrSHysW5XEjwZ494V60ybdpOo/s96jDD3sn7SkYolzg2Pboh+SW5Q7SVNcdkP4b9wCEizOYe9CB3vj3W6w==}
@@ -5746,6 +5753,19 @@ snapshots:
tslib: 2.8.1
tsyringe: 4.10.0
'@peculiar/x509@2.0.0':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-csr': 2.6.1
'@peculiar/asn1-ecc': 2.6.1
'@peculiar/asn1-pkcs9': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
pvtsutils: 1.3.6
tslib: 2.8.1
tsyringe: 4.10.0
'@pnpm/config.env-replace@1.1.0': {}
'@pnpm/network.ca-file@1.0.2':
@@ -5853,10 +5873,10 @@ snapshots:
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartacme@9.3.0(socks@2.8.7)':
'@push.rocks/smartacme@9.3.1(socks@2.8.7)':
dependencies:
'@apiclient.xyz/cloudflare': 7.1.0
'@peculiar/x509': 1.14.3
'@peculiar/x509': 2.0.0
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdata': 7.1.3(socks@2.8.7)
'@push.rocks/smartdelay': 3.0.5
@@ -5866,17 +5886,21 @@ snapshots:
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer': 6.1.2
'@push.rocks/taskbuffer': 8.0.2
'@tsclass/tsclass': 9.5.0
reflect-metadata: 0.2.2
transitivePeerDependencies:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
- '@nuxt/kit'
- bare-abort-controller
- bare-buffer
- encoding
- gcp-metadata
- kerberos
- mongodb-client-encryption
- react
- react-native-b4a
- snappy
- socks
- supports-color
@@ -6382,7 +6406,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.0.0':
'@push.rocks/smartproxy@27.1.0':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.1
@@ -6538,6 +6562,11 @@ snapshots:
'@types/semver': 7.7.1
semver: 7.7.4
'@push.rocks/smartvpn@1.13.0':
dependencies:
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2
'@push.rocks/smartwatch@6.4.0':
dependencies:
'@push.rocks/lik': 6.4.0
@@ -6577,22 +6606,6 @@ snapshots:
- supports-color
- vue
'@push.rocks/taskbuffer@6.1.2':
dependencies:
'@design.estate/dees-element': 2.2.4
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/taskbuffer@8.0.2':
dependencies:
'@design.estate/dees-element': 2.2.4

110
readme.md
View File

@@ -4,7 +4,7 @@
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
## Issue Reporting and Security
@@ -23,6 +23,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- [DNS Server](#dns-server)
- [RADIUS Server](#radius-server)
- [Remote Ingress](#remote-ingress)
- [VPN Access Control](#vpn-access-control)
- [Certificate Management](#certificate-management)
- [Storage & Caching](#storage--caching)
- [Security Features](#security-features)
@@ -73,6 +74,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
- **Route-level VPN gating** — mark any route with `vpn: { required: true }` to restrict access to VPN clients only
- **Rootless operation** — auto-detects privileges: kernel TUN when running as root, userspace NAT (smoltcp) when not
- **Client management** — create, enable, disable, rotate keys, export WireGuard `.conf` files via OpsServer API
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
- **PROXY protocol v2** — in socket mode, the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
### ⚡ High Performance
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
@@ -89,7 +98,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 🖥️ OpsServer Dashboard
- **Web-based management interface** with real-time monitoring
- **JWT authentication** with session persistence
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
- **Remote ingress management** with connection token generation and one-click copy
- **Read-only configuration display** — DcRouter is configured through code
@@ -248,6 +257,13 @@ const router = new DcRouter({
hubDomain: 'hub.example.com',
},
// VPN — restrict sensitive routes to VPN clients
vpnConfig: {
enabled: true,
serverEndpoint: 'vpn.example.com',
wgListenPort: 51820,
},
// Persistent storage
storage: { fsPath: '/var/lib/dcrouter/data' },
@@ -276,6 +292,7 @@ graph TB
DNS[DNS Queries]
RAD[RADIUS Clients]
EDGE[Edge Nodes]
VPN[VPN Clients]
end
subgraph "DcRouter Core"
@@ -285,6 +302,7 @@ graph TB
DS[SmartDNS Server<br/><i>Rust-powered</i>]
RS[SmartRadius Server]
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
VS[SmartVPN Server<br/><i>Rust data plane</i>]
CM[Certificate Manager<br/><i>smartacme v9</i>]
OS[OpsServer Dashboard]
MM[Metrics Manager]
@@ -305,12 +323,14 @@ graph TB
DNS --> DS
RAD --> RS
EDGE --> RI
VPN --> VS
DC --> SP
DC --> ES
DC --> DS
DC --> RS
DC --> RI
DC --> VS
DC --> CM
DC --> OS
DC --> MM
@@ -428,6 +448,17 @@ interface IDcRouterOptions {
};
};
// ── VPN ───────────────────────────────────────────────────────
/** VPN server for route-level access control */
vpnConfig?: {
enabled?: boolean; // default: false
subnet?: string; // default: '10.8.0.0/24'
wgListenPort?: number; // default: 51820
dns?: string[]; // DNS servers pushed to VPN clients
serverEndpoint?: string; // Hostname in generated client configs
forwardingMode?: 'tun' | 'socket'; // default: auto-detect (root → tun, else socket)
};
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
http3?: {
@@ -975,6 +1006,78 @@ The OpsServer Remote Ingress view provides:
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
| **Delete** | Remove the edge registration |
## VPN Access Control
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
### How It Works
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
3. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected with the VPN subnet
4. SmartProxy enforces the allowlist — only VPN-sourced traffic is accepted on those routes
### Two Operating Modes
| Mode | Root Required? | How It Works |
|------|---------------|-------------|
| **TUN** (`forwardingMode: 'tun'`) | Yes | Kernel TUN device — VPN traffic enters the network stack with real VPN IPs |
| **Socket** (`forwardingMode: 'socket'`) | No | Userspace NAT via smoltcp — outbound connections send PROXY protocol v2 to preserve VPN client IPs |
DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls back to socket mode. You can override this with the `forwardingMode` option.
### Configuration
```typescript
const router = new DcRouter({
vpnConfig: {
enabled: true,
subnet: '10.8.0.0/24', // VPN client IP pool (default)
wgListenPort: 51820, // WireGuard UDP port (default)
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
// forwardingMode: 'socket', // Override auto-detection
},
smartProxyConfig: {
routes: [
// This route is VPN-only — non-VPN clients are blocked
{
name: 'admin-panel',
match: { domains: ['admin.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.50', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
vpn: { required: true }, // 🔐 Only VPN clients can access this
},
// This route is public — anyone can access it
{
name: 'public-site',
match: { domains: ['example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 80 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
],
},
});
```
### Client Management via OpsServer API
Once the VPN server is running, you can manage clients through the OpsServer dashboard or API:
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
- **Enable / Disable** — toggle client access without deleting
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
- **Export config** — re-export in WireGuard or SmartVPN format
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or QR code — no custom VPN software needed.
## Certificate Management
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
@@ -1458,6 +1561,7 @@ The container exposes all service ports:
| 1812, 1813 | UDP | RADIUS auth/acct |
| 3000 | TCP | OpsServer dashboard |
| 8443 | TCP | Remote ingress tunnels |
| 51820 | UDP | WireGuard VPN |
| 2900030000 | TCP | Dynamic port range |
### Building the Image
@@ -1471,7 +1575,7 @@ The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsd
## 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.

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '11.12.3',
version: '11.15.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -188,6 +189,32 @@ export interface IDcRouterOptions {
keyPath?: string;
};
};
/**
* VPN server configuration.
* Enables VPN-based access control: routes with vpn.required are only
* accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports.
*/
vpnConfig?: {
/** Enable VPN server (default: false) */
enabled?: boolean;
/** VPN subnet CIDR (default: '10.8.0.0/24') */
subnet?: string;
/** WireGuard UDP listen port (default: 51820) */
wgListenPort?: number;
/** DNS servers pushed to VPN clients */
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
forwardingMode?: 'tun' | 'socket';
/** Pre-defined VPN clients created on startup */
clients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
};
}
/**
@@ -226,6 +253,9 @@ export class DcRouter {
public remoteIngressManager?: RemoteIngressManager;
public tunnelManager?: TunnelManager;
// VPN
public vpnManager?: VpnManager;
// Programmatic config API
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
@@ -429,6 +459,14 @@ export class DcRouter {
() => this.getConstructorRoutes(),
() => this.smartProxy,
() => this.options.http3,
this.options.vpnConfig?.enabled
? (tags?: string[]) => {
if (tags?.length && this.vpnManager) {
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
}
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
}
: undefined,
);
this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize();
@@ -533,6 +571,25 @@ export class DcRouter {
);
}
// VPN Server: optional, depends on SmartProxy
if (this.options.vpnConfig?.enabled) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('VpnServer')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupVpnServer();
})
.withStop(async () => {
if (this.vpnManager) {
await this.vpnManager.stop();
this.vpnManager = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// Wire up aggregated events for logging
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info';
@@ -616,6 +673,15 @@ export class DcRouter {
logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
}
// VPN summary
if (this.vpnManager && this.options.vpnConfig?.enabled) {
const subnet = this.vpnManager.getSubnet();
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
const mode = this.vpnManager.forwardingMode;
const clientCount = this.vpnManager.listClients().length;
logger.log('info', `VPN Service: mode=${mode}, subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
}
// Remote Ingress summary
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
@@ -741,6 +807,11 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
}
// VPN route security injection: restrict vpn.required routes to VPN subnet
if (this.options.vpnConfig?.enabled) {
routes = this.injectVpnSecurity(routes);
}
// Cache constructor routes for RouteConfigManager
this.constructorRoutes = [...routes];
@@ -852,14 +923,22 @@ export class DcRouter {
const cert = await this.smartAcme!.getCertificateForDomain(domain, {
includeWildcard: !isWildcardDomain,
});
if (cert.validUntil) {
eventComms.setExpiryDate(new Date(cert.validUntil));
// Parse real X509 expiry from PEM (defense-in-depth over SmartAcme's estimate)
let realValidUntil = cert.validUntil;
if (cert.publicKey) {
try {
const x509 = new plugins.crypto.X509Certificate(cert.publicKey);
realValidUntil = new Date(x509.validTo).getTime();
} catch { /* fallback to SmartAcme's value */ }
}
if (realValidUntil) {
eventComms.setExpiryDate(new Date(realValidUntil));
}
const result = {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
validUntil: cert.validUntil,
validUntil: realValidUntil,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr,
@@ -884,6 +963,22 @@ export class DcRouter {
smartProxyConfig.proxyIPs = ['127.0.0.1'];
}
// When VPN is in socket mode, the userspace NAT engine sends PP v2 headers
// on outbound connections to SmartProxy to preserve VPN client tunnel IPs.
if (this.options.vpnConfig?.enabled) {
const vpnForwardingMode = this.options.vpnConfig.forwardingMode
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
if (vpnForwardingMode === 'socket') {
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
}
}
// Create SmartProxy instance
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
@@ -1988,6 +2083,75 @@ export class DcRouter {
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
}
/**
* Set up VPN server for VPN-based route access control.
*/
private async setupVpnServer(): Promise<void> {
if (!this.options.vpnConfig?.enabled) {
return;
}
logger.log('info', 'Setting up VPN server...');
this.vpnManager = new VpnManager(this.storageManager, {
subnet: this.options.vpnConfig.subnet,
wgListenPort: this.options.vpnConfig.wgListenPort,
dns: this.options.vpnConfig.dns,
serverEndpoint: this.options.vpnConfig.serverEndpoint,
forwardingMode: this.options.vpnConfig.forwardingMode,
initialClients: this.options.vpnConfig.clients,
onClientChanged: () => {
// Re-apply routes so tag-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();
},
});
await this.vpnManager.start();
}
/**
* Inject VPN security into routes that have vpn.required === true.
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
*/
private injectVpnSecurity(routes: plugins.smartproxy.IRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
const vpnSubnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
let injectedCount = 0;
const result = routes.map((route) => {
const dcrouterRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
if (dcrouterRoute.vpn?.required) {
injectedCount++;
const existing = route.security?.ipAllowList || [];
let vpnAllowList: string[];
if (dcrouterRoute.vpn.allowedServerDefinedClientTags?.length && this.vpnManager) {
// Tag-based: only specific client IPs
vpnAllowList = this.vpnManager.getClientIpsForServerDefinedTags(
dcrouterRoute.vpn.allowedServerDefinedClientTags,
);
} else {
// No tags specified: entire VPN subnet
vpnAllowList = [vpnSubnet];
}
return {
...route,
security: {
...route.security,
ipAllowList: [...existing, ...vpnAllowList],
},
};
}
return route;
});
if (injectedCount > 0) {
logger.log('info', `VPN: Injected ipAllowList into ${injectedCount} VPN-protected route(s)`);
}
return result;
}
/**
* Set up RADIUS server for network authentication
*/

View File

@@ -7,6 +7,7 @@ import type {
IMergedRoute,
IRouteWarning,
} from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
const ROUTES_PREFIX = '/config-api/routes/';
@@ -22,6 +23,7 @@ export class RouteConfigManager {
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnAllowList?: (tags?: string[]) => string[],
) {}
/**
@@ -244,7 +246,7 @@ export class RouteConfigManager {
// Private: apply merged routes to SmartProxy
// =========================================================================
private async applyRoutes(): Promise<void> {
public async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
@@ -260,15 +262,31 @@ export class RouteConfigManager {
enabledRoutes.push(route);
}
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
const http3Config = this.getHttp3Config?.();
const vpnAllowList = this.getVpnAllowList;
for (const stored of this.storedRoutes.values()) {
if (stored.enabled) {
let route = stored.route;
if (http3Config && http3Config.enabled !== false) {
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
} else {
enabledRoutes.push(stored.route);
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
}
// Inject VPN security for programmatic routes with vpn.required
if (vpnAllowList) {
const dcRoute = route as IDcRouterRouteConfig;
if (dcRoute.vpn?.required) {
const existing = route.security?.ipAllowList || [];
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
route = {
...route,
security: {
...route.security,
ipAllowList: [...existing, ...allowList],
},
};
}
}
enabledRoutes.push(route);
}
}

View File

@@ -28,6 +28,7 @@ export class OpsServer {
private remoteIngressHandler!: handlers.RemoteIngressHandler;
private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler!: handlers.ApiTokenHandler;
private vpnHandler!: handlers.VpnHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -86,6 +87,7 @@ export class OpsServer {
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
this.vpnHandler = new handlers.VpnHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -8,4 +8,5 @@ export * from './email-ops.handler.js';
export * from './certificate.handler.js';
export * from './remoteingress.handler.js';
export * from './route-management.handler.js';
export * from './api-token.handler.js';
export * from './api-token.handler.js';
export * from './vpn.handler.js';

View File

@@ -0,0 +1,257 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class VpnHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get all registered VPN clients
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
'getVpnClients',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { clients: [] };
}
const clients = manager.listClients().map((c) => ({
clientId: c.clientId,
enabled: c.enabled,
serverDefinedClientTags: c.serverDefinedClientTags,
description: c.description,
assignedIp: c.assignedIp,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
expiresAt: c.expiresAt,
}));
return { clients };
},
),
);
// Get VPN server status
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
'getVpnStatus',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
if (!manager) {
return {
status: {
running: false,
forwardingMode: 'socket' as const,
subnet: vpnConfig?.subnet || '10.8.0.0/24',
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: null,
registeredClients: 0,
connectedClients: 0,
},
};
}
const connected = await manager.getConnectedClients();
return {
status: {
running: manager.running,
forwardingMode: manager.forwardingMode,
subnet: manager.getSubnet(),
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: manager.getServerPublicKeys(),
registeredClients: manager.listClients().length,
connectedClients: connected.length,
},
};
},
),
);
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Create a new VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
'createVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const bundle = await manager.createClient({
clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
description: dataArg.description,
});
return {
success: true,
client: {
clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true,
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
description: bundle.entry.description,
assignedIp: bundle.entry.assignedIp,
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: bundle.entry.expiresAt,
},
wireguardConfig: bundle.wireguardConfig,
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Delete a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
'deleteVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.removeClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Enable a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
'enableVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.enableClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Disable a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
'disableVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.disableClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Rotate a VPN client's keys
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
'rotateVpnClientKey',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const bundle = await manager.rotateClientKey(dataArg.clientId);
return {
success: true,
wireguardConfig: bundle.wireguardConfig,
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Export a VPN client config
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
'exportVpnClientConfig',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const config = await manager.exportClientConfig(dataArg.clientId, dataArg.format);
return { success: true, config };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Get telemetry for a specific VPN client
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
'getVpnClientTelemetry',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const telemetry = await manager.getClientTelemetry(dataArg.clientId);
if (!telemetry) {
return { success: false, message: 'Client not found or not connected' };
}
return {
success: true,
telemetry: {
clientId: telemetry.clientId,
assignedIp: telemetry.assignedIp,
bytesSent: telemetry.bytesSent,
bytesReceived: telemetry.bytesReceived,
packetsDropped: telemetry.packetsDropped,
bytesDropped: telemetry.bytesDropped,
lastKeepaliveAt: telemetry.lastKeepaliveAt,
keepalivesReceived: telemetry.keepalivesReceived,
rateLimitBytesPerSec: telemetry.rateLimitBytesPerSec,
burstBytes: telemetry.burstBytes,
},
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}

View File

@@ -58,13 +58,14 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartvpn from '@push.rocks/smartvpn';
import * as smartradius from '@push.rocks/smartradius';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique';
import * as taskbuffer from '@push.rocks/taskbuffer';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer };
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
// Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';

View File

@@ -0,0 +1,429 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/classes.storagemanager.js';
const STORAGE_PREFIX_KEYS = '/vpn/server-keys';
const STORAGE_PREFIX_CLIENTS = '/vpn/clients/';
export interface IVpnManagerConfig {
/** VPN subnet CIDR (default: '10.8.0.0/24') */
subnet?: string;
/** WireGuard UDP listen port (default: 51820) */
wgListenPort?: number;
/** DNS servers pushed to VPN clients */
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
forwardingMode?: 'tun' | 'socket';
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
initialClients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
}
interface IPersistedServerKeys {
noisePrivateKey: string;
noisePublicKey: string;
wgPrivateKey: string;
wgPublicKey: string;
}
interface IPersistedClient {
clientId: string;
enabled: boolean;
serverDefinedClientTags?: string[];
description?: string;
assignedIp?: string;
noisePublicKey: string;
wgPublicKey: string;
createdAt: number;
updatedAt: number;
expiresAt?: string;
/** @deprecated Legacy field — migrated to serverDefinedClientTags on load */
tags?: string[];
}
/**
* Manages the SmartVPN server lifecycle and VPN client CRUD.
* Persists server keys and client registrations via StorageManager.
*/
export class VpnManager {
private storageManager: StorageManager;
private config: IVpnManagerConfig;
private vpnServer?: plugins.smartvpn.VpnServer;
private clients: Map<string, IPersistedClient> = new Map();
private serverKeys?: IPersistedServerKeys;
private _forwardingMode: 'tun' | 'socket';
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
this.storageManager = storageManager;
this.config = config;
// Auto-detect forwarding mode: tun if root, socket otherwise
this._forwardingMode = config.forwardingMode
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
}
/** The effective forwarding mode (tun or socket). */
public get forwardingMode(): 'tun' | 'socket' {
return this._forwardingMode;
}
/** The VPN subnet CIDR. */
public getSubnet(): string {
return this.config.subnet || '10.8.0.0/24';
}
/** Whether the VPN server is running. */
public get running(): boolean {
return this.vpnServer?.running ?? false;
}
/**
* Start the VPN server.
* Loads or generates server keys, loads persisted clients, starts VpnServer.
*/
public async start(): Promise<void> {
// Load or generate server keys
this.serverKeys = await this.loadOrGenerateServerKeys();
// Load persisted clients
await this.loadPersistedClients();
// Build client entries for the daemon
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
for (const client of this.clients.values()) {
clientEntries.push({
clientId: client.clientId,
publicKey: client.noisePublicKey,
wgPublicKey: client.wgPublicKey,
enabled: client.enabled,
serverDefinedClientTags: client.serverDefinedClientTags,
description: client.description,
assignedIp: client.assignedIp,
expiresAt: client.expiresAt,
});
}
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
// Create and start VpnServer
this.vpnServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: this._forwardingMode,
transportMode: 'all',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: this._forwardingMode === 'socket',
};
await this.vpnServer.start(serverConfig);
// Create initial clients from config (idempotent — skip already-persisted)
if (this.config.initialClients) {
for (const initial of this.config.initialClients) {
if (!this.clients.has(initial.clientId)) {
const bundle = await this.createClient({
clientId: initial.clientId,
serverDefinedClientTags: initial.serverDefinedClientTags,
description: initial.description,
});
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
}
}
}
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
/**
* Stop the VPN server.
*/
public async stop(): Promise<void> {
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
} catch {
// Ignore stop errors
}
this.vpnServer.stop();
this.vpnServer = undefined;
}
logger.log('info', 'VPN server stopped');
}
// ── Client CRUD ────────────────────────────────────────────────────────
/**
* Create a new VPN client. Returns the config bundle (secrets only shown once).
*/
public async createClient(opts: {
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
const bundle = await this.vpnServer.createClient({
clientId: opts.clientId,
serverDefinedClientTags: opts.serverDefinedClientTags,
description: opts.description,
});
// Update WireGuard config endpoint if serverEndpoint is configured
if (this.config.serverEndpoint && bundle.wireguardConfig) {
const wgPort = this.config.wgListenPort ?? 51820;
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/Endpoint\s*=\s*.+/,
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
);
}
// Persist client entry (without private keys)
const persisted: IPersistedClient = {
clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true,
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
description: bundle.entry.description,
assignedIp: bundle.entry.assignedIp,
noisePublicKey: bundle.entry.publicKey,
wgPublicKey: bundle.entry.wgPublicKey || '',
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: bundle.entry.expiresAt,
};
this.clients.set(persisted.clientId, persisted);
await this.persistClient(persisted);
this.config.onClientChanged?.();
return bundle;
}
/**
* Remove a VPN client.
*/
public async removeClient(clientId: string): Promise<void> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
await this.vpnServer.removeClient(clientId);
this.clients.delete(clientId);
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
this.config.onClientChanged?.();
}
/**
* List all registered clients (without secrets).
*/
public listClients(): IPersistedClient[] {
return [...this.clients.values()];
}
/**
* Enable a client.
*/
public async enableClient(clientId: string): Promise<void> {
if (!this.vpnServer) throw new Error('VPN server not running');
await this.vpnServer.enableClient(clientId);
const client = this.clients.get(clientId);
if (client) {
client.enabled = true;
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.config.onClientChanged?.();
}
/**
* Disable a client.
*/
public async disableClient(clientId: string): Promise<void> {
if (!this.vpnServer) throw new Error('VPN server not running');
await this.vpnServer.disableClient(clientId);
const client = this.clients.get(clientId);
if (client) {
client.enabled = false;
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.config.onClientChanged?.();
}
/**
* Rotate a client's keys. Returns the new config bundle.
*/
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) throw new Error('VPN server not running');
const bundle = await this.vpnServer.rotateClientKey(clientId);
// Update endpoint in WireGuard config
if (this.config.serverEndpoint && bundle.wireguardConfig) {
const wgPort = this.config.wgListenPort ?? 51820;
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/Endpoint\s*=\s*.+/,
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
);
}
// Update persisted entry with new public keys
const client = this.clients.get(clientId);
if (client) {
client.noisePublicKey = bundle.entry.publicKey;
client.wgPublicKey = bundle.entry.wgPublicKey || '';
client.updatedAt = Date.now();
await this.persistClient(client);
}
return bundle;
}
/**
* Export a client config (without secrets).
*/
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
if (!this.vpnServer) throw new Error('VPN server not running');
let config = await this.vpnServer.exportClientConfig(clientId, format);
// Update endpoint in WireGuard config
if (format === 'wireguard' && this.config.serverEndpoint) {
const wgPort = this.config.wgListenPort ?? 51820;
config = config.replace(
/Endpoint\s*=\s*.+/,
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
);
}
return config;
}
// ── Tag-based access control ───────────────────────────────────────────
/**
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
*/
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
const ips: string[] = [];
for (const client of this.clients.values()) {
if (!client.enabled || !client.assignedIp) continue;
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
ips.push(client.assignedIp);
}
}
return ips;
}
// ── Status and telemetry ───────────────────────────────────────────────
/**
* Get server status.
*/
public async getStatus(): Promise<plugins.smartvpn.IVpnStatus | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getStatus();
}
/**
* Get server statistics.
*/
public async getStatistics(): Promise<plugins.smartvpn.IVpnServerStatistics | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getStatistics();
}
/**
* List currently connected clients.
*/
public async getConnectedClients(): Promise<plugins.smartvpn.IVpnClientInfo[]> {
if (!this.vpnServer) return [];
return this.vpnServer.listClients();
}
/**
* Get telemetry for a specific client.
*/
public async getClientTelemetry(clientId: string): Promise<plugins.smartvpn.IVpnClientTelemetry | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getClientTelemetry(clientId);
}
/**
* Get server public keys (for display/info).
*/
public getServerPublicKeys(): { noisePublicKey: string; wgPublicKey: string } | null {
if (!this.serverKeys) return null;
return {
noisePublicKey: this.serverKeys.noisePublicKey,
wgPublicKey: this.serverKeys.wgPublicKey,
};
}
// ── Private helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<IPersistedServerKeys> {
const stored = await this.storageManager.getJSON<IPersistedServerKeys>(STORAGE_PREFIX_KEYS);
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
logger.log('info', 'Loaded VPN server keys from storage');
return stored;
}
// Generate new keys via the daemon
const tempServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
await tempServer.start();
const noiseKeys = await tempServer.generateKeypair();
const wgKeys = await tempServer.generateWgKeypair();
tempServer.stop();
const keys: IPersistedServerKeys = {
noisePrivateKey: noiseKeys.privateKey,
noisePublicKey: noiseKeys.publicKey,
wgPrivateKey: wgKeys.privateKey,
wgPublicKey: wgKeys.publicKey,
};
await this.storageManager.setJSON(STORAGE_PREFIX_KEYS, keys);
logger.log('info', 'Generated and persisted new VPN server keys');
return keys;
}
private async loadPersistedClients(): Promise<void> {
const keys = await this.storageManager.list(STORAGE_PREFIX_CLIENTS);
for (const key of keys) {
const client = await this.storageManager.getJSON<IPersistedClient>(key);
if (client) {
// Migrate legacy `tags` → `serverDefinedClientTags`
if (!client.serverDefinedClientTags && client.tags) {
client.serverDefinedClientTags = client.tags;
delete client.tags;
await this.persistClient(client);
}
this.clients.set(client.clientId, client);
}
}
if (this.clients.size > 0) {
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
}
}
private async persistClient(client: IPersistedClient): Promise<void> {
await this.storageManager.setJSON(`${STORAGE_PREFIX_CLIENTS}${client.clientId}`, client);
}
}

1
ts/vpn/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './classes.vpn-manager.js';

View File

@@ -1,4 +1,5 @@
export * from './auth.js';
export * from './stats.js';
export * from './remoteingress.js';
export * from './route-management.js';
export * from './route-management.js';
export * from './vpn.js';

View File

@@ -51,11 +51,23 @@ export interface IRouteRemoteIngress {
edgeFilter?: string[];
}
/**
* Route-level VPN access configuration.
* When attached to a route, restricts access to VPN clients only.
*/
export interface IRouteVpn {
/** Whether this route requires VPN access */
required: boolean;
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
allowedServerDefinedClientTags?: string[];
}
/**
* Extended route config used within dcrouter.
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
* SmartProxy ignores unknown properties at runtime.
*/
export type IDcRouterRouteConfig = IRouteConfig & {
remoteIngress?: IRouteRemoteIngress;
vpn?: IRouteVpn;
};

45
ts_interfaces/data/vpn.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* A registered VPN client (secrets excluded from API responses).
*/
export interface IVpnClient {
clientId: string;
enabled: boolean;
serverDefinedClientTags?: string[];
description?: string;
assignedIp?: string;
createdAt: number;
updatedAt: number;
expiresAt?: string;
}
/**
* VPN server status.
*/
export interface IVpnServerStatus {
running: boolean;
forwardingMode: 'tun' | 'socket';
subnet: string;
wgListenPort: number;
serverPublicKeys: {
noisePublicKey: string;
wgPublicKey: string;
} | null;
registeredClients: number;
connectedClients: number;
}
/**
* VPN client telemetry data.
*/
export interface IVpnClientTelemetry {
clientId: string;
assignedIp: string;
bytesSent: number;
bytesReceived: number;
packetsDropped: number;
bytesDropped: number;
lastKeepaliveAt?: string;
keepalivesReceived: number;
rateLimitBytesPerSec?: number;
burstBytes?: number;
}

View File

@@ -96,7 +96,15 @@ interface IIdentity {
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` property |
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
| `IRouteVpn` | Route-level VPN config: `required` flag to restrict access to VPN clients |
#### VPN Interfaces
| Interface | Description |
|-----------|-------------|
| `IVpnClient` | Client registration: clientId, enabled, tags, description, assignedIp, timestamps |
| `IVpnServerStatus` | Server status: running, forwardingMode, subnet, wgListenPort, publicKeys, client counts |
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
### Request Interfaces (`requests`)
@@ -205,6 +213,19 @@ interface ICertificateInfo {
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
#### 🔐 VPN
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
#### 📡 RADIUS
| Interface | Method | Description |
|-----------|--------|-------------|

View File

@@ -8,4 +8,5 @@ export * from './email-ops.js';
export * from './certificate.js';
export * from './remoteingress.js';
export * from './route-management.js';
export * from './api-tokens.js';
export * from './api-tokens.js';
export * from './vpn.js';

View File

@@ -0,0 +1,175 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry } from '../data/vpn.js';
// ============================================================================
// VPN Client Management
// ============================================================================
/**
* Get all registered VPN clients.
*/
export interface IReq_GetVpnClients extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetVpnClients
> {
method: 'getVpnClients';
request: {
identity: authInterfaces.IIdentity;
};
response: {
clients: IVpnClient[];
};
}
/**
* Get VPN server status.
*/
export interface IReq_GetVpnStatus extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetVpnStatus
> {
method: 'getVpnStatus';
request: {
identity: authInterfaces.IIdentity;
};
response: {
status: IVpnServerStatus;
};
}
/**
* Create a new VPN client. Returns the config bundle (secrets only shown once).
*/
export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateVpnClient
> {
method: 'createVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
};
response: {
success: boolean;
client?: IVpnClient;
/** WireGuard .conf file content (only returned at creation) */
wireguardConfig?: string;
message?: string;
};
}
/**
* Delete a VPN client.
*/
export interface IReq_DeleteVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteVpnClient
> {
method: 'deleteVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Enable a VPN client.
*/
export interface IReq_EnableVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_EnableVpnClient
> {
method: 'enableVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Disable a VPN client.
*/
export interface IReq_DisableVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DisableVpnClient
> {
method: 'disableVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Rotate a VPN client's keys. Returns the new config bundle.
*/
export interface IReq_RotateVpnClientKey extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RotateVpnClientKey
> {
method: 'rotateVpnClientKey';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
/** WireGuard .conf file content with new keys */
wireguardConfig?: string;
message?: string;
};
}
/**
* Export a VPN client config.
*/
export interface IReq_ExportVpnClientConfig extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ExportVpnClientConfig
> {
method: 'exportVpnClientConfig';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
format: 'smartvpn' | 'wireguard';
};
response: {
success: boolean;
config?: string;
message?: string;
};
}
/**
* Get telemetry for a specific VPN client.
*/
export interface IReq_GetVpnClientTelemetry extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetVpnClientTelemetry
> {
method: 'getVpnClientTelemetry';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
telemetry?: IVpnClientTelemetry;
message?: string;
};
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '11.12.3',
version: '11.15.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -905,6 +905,161 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
}
});
// ============================================================================
// VPN State
// ============================================================================
export interface IVpnState {
clients: interfaces.data.IVpnClient[];
status: interfaces.data.IVpnServerStatus | null;
isLoading: boolean;
error: string | null;
lastUpdated: number;
/** WireGuard config shown after create/rotate (only shown once) */
newClientConfig: string | null;
}
export const vpnStatePart = await appState.getStatePart<IVpnState>(
'vpn',
{
clients: [],
status: null,
isLoading: false,
error: null,
lastUpdated: 0,
newClientConfig: null,
},
'soft'
);
// ============================================================================
// VPN Actions
// ============================================================================
export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const clientsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnClients
>('/typedrequest', 'getVpnClients');
const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnStatus
>('/typedrequest', 'getVpnStatus');
const [clientsResponse, statusResponse] = await Promise.all([
clientsRequest.fire({ identity: context.identity }),
statusRequest.fire({ identity: context.identity }),
]);
return {
...currentState,
clients: clientsResponse.clients,
status: statusResponse.status,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch VPN data',
};
}
});
export const createVpnClientAction = vpnStatePart.createAction<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateVpnClient
>('/typedrequest', 'createVpnClient');
const response = await request.fire({
identity: context.identity!,
clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
description: dataArg.description,
});
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to create client' };
}
const refreshed = await actionContext!.dispatch(fetchVpnAction, null);
return {
...refreshed,
newClientConfig: response.wireguardConfig || null,
};
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to create VPN client',
};
}
});
export const deleteVpnClientAction = vpnStatePart.createAction<string>(
async (statePartArg, clientId, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteVpnClient
>('/typedrequest', 'deleteVpnClient');
await request.fire({ identity: context.identity!, clientId });
return await actionContext!.dispatch(fetchVpnAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete VPN client',
};
}
},
);
export const toggleVpnClientAction = vpnStatePart.createAction<{
clientId: string;
enabled: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const method = dataArg.enabled ? 'enableVpnClient' : 'disableVpnClient';
type TReq = interfaces.requests.IReq_EnableVpnClient | interfaces.requests.IReq_DisableVpnClient;
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<TReq>(
'/typedrequest', method,
);
await request.fire({ identity: context.identity!, clientId: dataArg.clientId });
return await actionContext!.dispatch(fetchVpnAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to toggle VPN client',
};
}
});
export const clearNewClientConfigAction = vpnStatePart.createAction(
async (statePartArg): Promise<IVpnState> => {
return { ...statePartArg.getState()!, newClientConfig: null };
},
);
// ============================================================================
// Route Management Actions
// ============================================================================
@@ -1372,6 +1527,15 @@ async function dispatchCombinedRefreshActionInner() {
console.error('Remote ingress refresh failed:', error);
}
}
// Refresh VPN data if on vpn view
if (currentView === 'vpn') {
try {
await vpnStatePart.dispatchAction(fetchVpnAction, null);
} catch (error) {
console.error('VPN refresh failed:', error);
}
}
} catch (error) {
console.error('Combined refresh failed:', error);
// If the error looks like an auth failure (invalid JWT), force re-login

View File

@@ -9,4 +9,5 @@ export * from './ops-view-apitokens.js';
export * from './ops-view-security.js';
export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js';
export * from './ops-view-vpn.js';
export * from './shared/index.js';

View File

@@ -24,6 +24,7 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js';
import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
import { OpsViewVpn } from './ops-view-vpn.js';
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement {
iconName: 'lucide:globe',
element: OpsViewRemoteIngress,
},
{
name: 'VPN',
iconName: 'lucide:shield',
element: OpsViewVpn,
},
];
/**

View File

@@ -0,0 +1,330 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-vpn': OpsViewVpn;
}
}
@customElement('ops-view-vpn')
export class OpsViewVpn extends DeesElement {
@state()
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
constructor() {
super();
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
this.vpnState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.vpnContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.statusBadge.enabled {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.configDialog {
padding: 16px;
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
border-radius: 8px;
margin-bottom: 16px;
}
.configDialog pre {
display: block;
padding: 12px;
background: ${cssManager.bdTheme('#1f2937', '#111827')};
color: #10b981;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
margin: 8px 0;
user-select: all;
max-height: 300px;
overflow-y: auto;
}
.configDialog .warning {
font-size: 12px;
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
margin-top: 8px;
}
.tagBadge {
display: inline-flex;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
margin-right: 4px;
}
.serverInfo {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
border-radius: 8px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')};
}
.serverInfo .infoItem {
display: flex;
flex-direction: column;
gap: 4px;
}
.serverInfo .infoLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.serverInfo .infoValue {
font-size: 14px;
font-family: monospace;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
`,
];
render(): TemplateResult {
const status = this.vpnState.status;
const clients = this.vpnState.clients;
const connectedCount = status?.connectedClients ?? 0;
const totalClients = clients.length;
const enabledClients = clients.filter(c => c.enabled).length;
const statsTiles: IStatsTile[] = [
{
id: 'totalClients',
title: 'Total Clients',
type: 'number',
value: totalClients,
icon: 'lucide:users',
description: 'Registered VPN clients',
color: '#3b82f6',
},
{
id: 'connectedClients',
title: 'Connected',
type: 'number',
value: connectedCount,
icon: 'lucide:link',
description: 'Currently connected',
color: '#10b981',
},
{
id: 'enabledClients',
title: 'Enabled',
type: 'number',
value: enabledClients,
icon: 'lucide:shieldCheck',
description: 'Active client registrations',
color: '#8b5cf6',
},
{
id: 'serverStatus',
title: 'Server',
type: 'text',
value: status?.running ? 'Running' : 'Stopped',
icon: 'lucide:server',
description: status?.running ? `${status.forwardingMode} mode` : 'VPN server not running',
color: status?.running ? '#10b981' : '#ef4444',
},
];
return html`
<ops-sectionheading>VPN</ops-sectionheading>
${this.vpnState.newClientConfig ? html`
<div class="configDialog">
<strong>Client created successfully!</strong>
<div class="warning">Copy the WireGuard config now. It contains private keys that won't be shown again.</div>
<pre>${this.vpnState.newClientConfig}</pre>
<dees-button
@click=${async () => {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(this.vpnState.newClientConfig!);
}
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 });
}}
>Copy to Clipboard</dees-button>
<dees-button
@click=${() => {
const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wireguard.conf';
a.click();
URL.revokeObjectURL(url);
}}
>Download .conf</dees-button>
<dees-button
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
>Dismiss</dees-button>
</div>
` : ''}
<dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
${status ? html`
<div class="serverInfo">
<div class="infoItem">
<span class="infoLabel">Subnet</span>
<span class="infoValue">${status.subnet}</span>
</div>
<div class="infoItem">
<span class="infoLabel">WireGuard Port</span>
<span class="infoValue">${status.wgListenPort}</span>
</div>
<div class="infoItem">
<span class="infoLabel">Forwarding Mode</span>
<span class="infoValue">${status.forwardingMode}</span>
</div>
${status.serverPublicKeys ? html`
<div class="infoItem">
<span class="infoLabel">WG Public Key</span>
<span class="infoValue" style="font-size: 11px; word-break: break-all;">${status.serverPublicKeys.wgPublicKey}</span>
</div>
` : ''}
</div>
` : ''}
<dees-table
.heading1=${'VPN Clients'}
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
.data=${clients}
.displayFunction=${(client: interfaces.data.IVpnClient) => ({
'Client ID': client.clientId,
'Status': client.enabled
? html`<span class="statusBadge enabled">enabled</span>`
: html`<span class="statusBadge disabled">disabled</span>`,
'VPN IP': client.assignedIp || '-',
'Tags': client.serverDefinedClientTags?.length
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
: '-',
'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(),
})}
.dataActions=${[
{
name: 'Toggle',
iconName: 'lucide:power',
action: async (client: interfaces.data.IVpnClient) => {
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
clientId: client.clientId,
enabled: !client.enabled,
});
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async (client: interfaces.data.IVpnClient) => {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Delete VPN Client',
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
{
name: 'Delete',
action: async (modal: any) => {
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
modal.destroy();
},
},
],
});
},
},
]}
.createNewItem=${async () => {
const { DeesModal, DeesForm, DeesInputText } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Create VPN Client',
content: html`
<dees-form>
<dees-input-text id="clientId" .label=${'Client ID'} .key=${'clientId'} required></dees-input-text>
<dees-input-text id="description" .label=${'Description'} .key=${'description'}></dees-input-text>
<dees-input-text id="tags" .label=${'Tags (comma-separated)'} .key=${'tags'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
{
name: 'Create',
action: async (modal: any) => {
const form = modal.shadowRoot!.querySelector('dees-form') as any;
const data = await form.collectFormData();
const serverDefinedClientTags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined;
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId,
description: data.description || undefined,
serverDefinedClientTags,
});
modal.destroy();
},
},
],
});
}}
></dees-table>
`;
}
}

View File

@@ -50,6 +50,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
- Enable/disable, edit, secret regeneration, and delete actions
### 🔐 VPN Management
- VPN server status with forwarding mode, subnet, and WireGuard port
- Client registration table with create, enable/disable, and delete actions
- WireGuard config download and clipboard copy on client creation
- Per-client telemetry (bytes sent/received, keepalives)
- Server public key display for manual client configuration
### 📜 Log Viewer
- Real-time log streaming
- Filter by log level (error, warning, info, debug)
@@ -100,6 +107,7 @@ ts_web/
├── ops-view-emails.ts # Email queue management
├── ops-view-certificates.ts # Certificate overview & reprovisioning
├── ops-view-remoteingress.ts # Remote ingress edge management
├── ops-view-vpn.ts # VPN client management
├── ops-view-logs.ts # Log viewer
├── ops-view-routes.ts # Route & API token management
├── ops-view-config.ts # Configuration display
@@ -124,6 +132,7 @@ The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
### Tab Visibility Optimization
@@ -173,6 +182,13 @@ regenerateRemoteIngressSecretAction(id) // New secret
toggleRemoteIngressAction(id, enabled) // Enable/disable
clearNewEdgeSecretAction() // Dismiss secret banner
fetchConnectionToken(edgeId) // Get connection token (standalone function)
// VPN
fetchVpnAction() // Clients + server status
createVpnClientAction(data) // Create new VPN client
deleteVpnClientAction(clientId) // Remove VPN client
toggleVpnClientAction(id, enabled) // Enable/disable
clearNewClientConfigAction() // Dismiss config banner
```
### Client-Side Routing
@@ -187,6 +203,7 @@ fetchConnectionToken(edgeId) // Get connection token (standalone function)
/emails/security → Security incidents
/certificates → Certificate management
/remoteingress → Remote ingress edge management
/vpn → VPN client management
/routes → Route & API token management
/logs → Log viewer
/configuration → System configuration

View File

@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn'] as const;
export type TValidView = typeof validViews[number];