Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 | |||
| d53cff6a94 | |||
| eb211348d2 | |||
| 43618abeba | |||
| dd9769b814 |
44
changelog.md
44
changelog.md
@@ -1,5 +1,49 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.19.0 - feat(vpn)
|
||||||
|
document tag-based VPN access control, declarative clients, and destination policy options
|
||||||
|
|
||||||
|
- Adds documentation for restricting VPN-protected routes with allowedServerDefinedClientTags.
|
||||||
|
- Documents pre-defined VPN clients in configuration via vpnConfig.clients.
|
||||||
|
- Describes destinationPolicy behavior for forceTarget, allow, and block traffic handling.
|
||||||
|
- Updates interface docs to reflect serverDefinedClientTags and revised VPN server status fields.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.18.0 - feat(vpn-ui)
|
||||||
|
add format selection for VPN client config exports
|
||||||
|
|
||||||
|
- Show an export modal that lets operators choose between WireGuard (.conf) and SmartVPN (.json) client configs.
|
||||||
|
- Update VPN client row actions to read the selected item from actionData for toggle, export, rotate keys, and delete handlers.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.17.0 - feat(vpn)
|
||||||
|
expand VPN operations view with client management and config export actions
|
||||||
|
|
||||||
|
- adds predefined VPN clients to the dev server configuration for local testing
|
||||||
|
- adds table actions to create clients, export WireGuard configs, rotate client keys, toggle access, and delete clients
|
||||||
|
- updates the VPN view layout and stats grid binding to match the current component API
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.16.0 - feat(vpn)
|
||||||
|
add destination-based VPN routing policy and standardize socket proxy forwarding
|
||||||
|
|
||||||
|
- replace configurable VPN forwarding mode with socket-based forwarding and always enable proxy protocol support to SmartProxy from localhost
|
||||||
|
- add destinationPolicy configuration for controlling default VPN traffic handling, including forceTarget, allow, and block rules
|
||||||
|
- remove forwarding mode reporting from VPN status APIs, logs, and ops UI to reflect the simplified VPN runtime model
|
||||||
|
- update @push.rocks/smartvpn to 1.14.0 to support the new VPN routing behavior
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-30 - 11.13.0 - feat(vpn)
|
||||||
add VPN server management and route-based VPN access control
|
add VPN server management and route-based VPN access control
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "11.13.0",
|
"version": "11.19.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.3.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartvpn": "1.12.0",
|
"@push.rocks/smartvpn": "1.14.0",
|
||||||
"@push.rocks/taskbuffer": "^8.0.2",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/catalog": "^2.9.0",
|
"@serve.zone/catalog": "^2.9.0",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -96,8 +96,8 @@ importers:
|
|||||||
specifier: ^3.0.9
|
specifier: ^3.0.9
|
||||||
version: 3.0.9
|
version: 3.0.9
|
||||||
'@push.rocks/smartvpn':
|
'@push.rocks/smartvpn':
|
||||||
specifier: 1.12.0
|
specifier: 1.14.0
|
||||||
version: 1.12.0
|
version: 1.14.0
|
||||||
'@push.rocks/taskbuffer':
|
'@push.rocks/taskbuffer':
|
||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
@@ -1330,8 +1330,8 @@ packages:
|
|||||||
'@push.rocks/smartversion@3.0.5':
|
'@push.rocks/smartversion@3.0.5':
|
||||||
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
||||||
|
|
||||||
'@push.rocks/smartvpn@1.12.0':
|
'@push.rocks/smartvpn@1.14.0':
|
||||||
resolution: {integrity: sha512-lwZCK8fopkms3c6ZSrUghuVNFi7xOXMSkGDSptQM2K3tu2UbajhpdxlAVMODY8n6caQr5ZXp0kHdtwVU9WKi5Q==}
|
resolution: {integrity: sha512-zJmHiuLwY4OEN4jBVrJf1hAXpfO9f6Bulq/v1DrB16nR7VgE82KNqRLt0Wi/9PCsAUfmVJTvOf4yirnjBrEWQg==}
|
||||||
|
|
||||||
'@push.rocks/smartwatch@6.4.0':
|
'@push.rocks/smartwatch@6.4.0':
|
||||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||||
@@ -6562,7 +6562,7 @@ snapshots:
|
|||||||
'@types/semver': 7.7.1
|
'@types/semver': 7.7.1
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
|
|
||||||
'@push.rocks/smartvpn@1.12.0':
|
'@push.rocks/smartvpn@1.14.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
|
|||||||
174
readme.md
174
readme.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
**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
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- [DNS Server](#dns-server)
|
- [DNS Server](#dns-server)
|
||||||
- [RADIUS Server](#radius-server)
|
- [RADIUS Server](#radius-server)
|
||||||
- [Remote Ingress](#remote-ingress)
|
- [Remote Ingress](#remote-ingress)
|
||||||
|
- [VPN Access Control](#vpn-access-control)
|
||||||
- [Certificate Management](#certificate-management)
|
- [Certificate Management](#certificate-management)
|
||||||
- [Storage & Caching](#storage--caching)
|
- [Storage & Caching](#storage--caching)
|
||||||
- [Security Features](#security-features)
|
- [Security Features](#security-features)
|
||||||
@@ -73,6 +74,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
- **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
|
- **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
|
||||||
|
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
|
||||||
|
- **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup
|
||||||
|
- **Rootless operation** — uses userspace NAT (smoltcp) with no root required
|
||||||
|
- **Destination policy** — configurable `forceTarget`, `block`, or `allow` with allowList/blockList for granular traffic control
|
||||||
|
- **Client management** — create, enable, disable, rotate keys, export WireGuard/SmartVPN configs via OpsServer API and dashboard
|
||||||
|
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
|
||||||
|
- **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
||||||
|
|
||||||
### ⚡ High Performance
|
### ⚡ High Performance
|
||||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||||
@@ -89,7 +101,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
### 🖥️ OpsServer Dashboard
|
### 🖥️ OpsServer Dashboard
|
||||||
- **Web-based management interface** with real-time monitoring
|
- **Web-based management interface** with real-time monitoring
|
||||||
- **JWT authentication** with session persistence
|
- **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
|
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||||
- **Remote ingress management** with connection token generation and one-click copy
|
- **Remote ingress management** with connection token generation and one-click copy
|
||||||
- **Read-only configuration display** — DcRouter is configured through code
|
- **Read-only configuration display** — DcRouter is configured through code
|
||||||
@@ -248,6 +260,15 @@ const router = new DcRouter({
|
|||||||
hubDomain: 'hub.example.com',
|
hubDomain: 'hub.example.com',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// VPN — restrict sensitive routes to VPN clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.example.com',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// Persistent storage
|
// Persistent storage
|
||||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
storage: { fsPath: '/var/lib/dcrouter/data' },
|
||||||
|
|
||||||
@@ -276,6 +297,7 @@ graph TB
|
|||||||
DNS[DNS Queries]
|
DNS[DNS Queries]
|
||||||
RAD[RADIUS Clients]
|
RAD[RADIUS Clients]
|
||||||
EDGE[Edge Nodes]
|
EDGE[Edge Nodes]
|
||||||
|
VPN[VPN Clients]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "DcRouter Core"
|
subgraph "DcRouter Core"
|
||||||
@@ -285,6 +307,7 @@ graph TB
|
|||||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||||
RS[SmartRadius Server]
|
RS[SmartRadius Server]
|
||||||
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
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>]
|
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||||
OS[OpsServer Dashboard]
|
OS[OpsServer Dashboard]
|
||||||
MM[Metrics Manager]
|
MM[Metrics Manager]
|
||||||
@@ -305,12 +328,14 @@ graph TB
|
|||||||
DNS --> DS
|
DNS --> DS
|
||||||
RAD --> RS
|
RAD --> RS
|
||||||
EDGE --> RI
|
EDGE --> RI
|
||||||
|
VPN --> VS
|
||||||
|
|
||||||
DC --> SP
|
DC --> SP
|
||||||
DC --> ES
|
DC --> ES
|
||||||
DC --> DS
|
DC --> DS
|
||||||
DC --> RS
|
DC --> RS
|
||||||
DC --> RI
|
DC --> RI
|
||||||
|
DC --> VS
|
||||||
DC --> CM
|
DC --> CM
|
||||||
DC --> OS
|
DC --> OS
|
||||||
DC --> MM
|
DC --> MM
|
||||||
@@ -428,6 +453,27 @@ 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
|
||||||
|
clients?: Array<{ // Pre-defined VPN clients
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
destinationPolicy?: { // Traffic routing policy
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
target?: string; // IP for forceTarget (default: '127.0.0.1')
|
||||||
|
allowList?: string[]; // Pass through directly
|
||||||
|
blockList?: string[]; // Always block (overrides allowList)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
||||||
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
||||||
http3?: {
|
http3?: {
|
||||||
@@ -975,6 +1021,127 @@ The OpsServer Remote Ingress view provides:
|
|||||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||||
| **Delete** | Remove the edge registration |
|
| **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
|
||||||
|
4. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
|
||||||
|
5. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
|
||||||
|
6. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
|
||||||
|
|
||||||
|
### Destination Policy
|
||||||
|
|
||||||
|
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Default: all traffic → SmartProxy
|
||||||
|
destinationPolicy: { default: 'forceTarget', target: '127.0.0.1' }
|
||||||
|
|
||||||
|
// Allow direct access to a backend subnet
|
||||||
|
destinationPolicy: {
|
||||||
|
default: 'forceTarget',
|
||||||
|
target: '127.0.0.1',
|
||||||
|
allowList: ['192.168.190.*'], // direct access to this subnet
|
||||||
|
blockList: ['192.168.190.1'], // except the gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block everything except specific IPs
|
||||||
|
destinationPolicy: {
|
||||||
|
default: 'block',
|
||||||
|
allowList: ['10.0.0.*', '192.168.1.*'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
// Pre-define VPN clients with server-defined tags
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'alice-laptop', serverDefinedClientTags: ['engineering'], description: 'Dev laptop' },
|
||||||
|
{ clientId: 'bob-phone', serverDefinedClientTags: ['engineering', 'mobile'] },
|
||||||
|
{ clientId: 'carol-desktop', serverDefinedClientTags: ['finance'] },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Optional: customize destination policy (default: forceTarget → localhost)
|
||||||
|
// destinationPolicy: { default: 'forceTarget', target: '127.0.0.1', allowList: ['192.168.1.*'] },
|
||||||
|
},
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: [
|
||||||
|
// 🔐 VPN-only: any VPN client can access
|
||||||
|
{
|
||||||
|
name: 'internal-app',
|
||||||
|
match: { domains: ['internal.example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.50', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
vpn: { required: true },
|
||||||
|
},
|
||||||
|
// 🔐 VPN + tag-restricted: only 'engineering' tagged clients
|
||||||
|
{
|
||||||
|
name: 'eng-dashboard',
|
||||||
|
match: { domains: ['eng.example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.51', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||||
|
// → alice + bob can access, carol cannot
|
||||||
|
},
|
||||||
|
// 🌐 Public: no VPN required
|
||||||
|
{
|
||||||
|
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 Tags
|
||||||
|
|
||||||
|
SmartVPN distinguishes between two types of client tags:
|
||||||
|
|
||||||
|
| Tag Type | Set By | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `serverDefinedClientTags` | Admin (via config or API) | **Trusted** — used for route access control |
|
||||||
|
| `clientDefinedClientTags` | Connecting client | **Informational** — displayed in dashboard, never used for security |
|
||||||
|
|
||||||
|
Routes with `allowedServerDefinedClientTags` only permit VPN clients whose admin-assigned tags match. Clients cannot influence their own server-defined tags.
|
||||||
|
|
||||||
|
### Client Management via OpsServer
|
||||||
|
|
||||||
|
The OpsServer dashboard and API provide full VPN client lifecycle management:
|
||||||
|
|
||||||
|
- **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** — download in WireGuard (`.conf`) or SmartVPN (`.json`) format
|
||||||
|
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
|
||||||
|
- **Delete** — remove a client and revoke access
|
||||||
|
|
||||||
|
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file — no custom VPN software needed.
|
||||||
|
|
||||||
## Certificate Management
|
## 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:
|
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 +1625,7 @@ The container exposes all service ports:
|
|||||||
| 1812, 1813 | UDP | RADIUS auth/acct |
|
| 1812, 1813 | UDP | RADIUS auth/acct |
|
||||||
| 3000 | TCP | OpsServer dashboard |
|
| 3000 | TCP | OpsServer dashboard |
|
||||||
| 8443 | TCP | Remote ingress tunnels |
|
| 8443 | TCP | Remote ingress tunnels |
|
||||||
|
| 51820 | UDP | WireGuard VPN |
|
||||||
| 29000–30000 | TCP | Dynamic port range |
|
| 29000–30000 | TCP | Dynamic port range |
|
||||||
|
|
||||||
### Building the Image
|
### Building the Image
|
||||||
@@ -1471,7 +1639,7 @@ The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsd
|
|||||||
|
|
||||||
## License and Legal Information
|
## 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ const devRouter = new DcRouter({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// VPN with pre-defined clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.dev.local',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' },
|
||||||
|
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' },
|
||||||
|
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' },
|
||||||
|
],
|
||||||
|
},
|
||||||
// Disable cache/mongo for dev
|
// Disable cache/mongo for dev
|
||||||
cacheConfig: { enabled: false },
|
cacheConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.13.0',
|
version: '11.19.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,8 +206,21 @@ export interface IDcRouterOptions {
|
|||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||||
serverEndpoint?: string;
|
serverEndpoint?: string;
|
||||||
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
/** Pre-defined VPN clients created on startup */
|
||||||
forwardingMode?: 'tun' | 'socket';
|
clients?: Array<{
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
/** Destination routing policy for VPN client traffic.
|
||||||
|
* Default in socket mode: { default: 'forceTarget', target: '127.0.0.1' } (all traffic → SmartProxy).
|
||||||
|
* Default in tun mode: not set (all traffic passes through). */
|
||||||
|
destinationPolicy?: {
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
target?: string;
|
||||||
|
allowList?: string[];
|
||||||
|
blockList?: string[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +466,14 @@ export class DcRouter {
|
|||||||
() => this.getConstructorRoutes(),
|
() => this.getConstructorRoutes(),
|
||||||
() => this.smartProxy,
|
() => this.smartProxy,
|
||||||
() => this.options.http3,
|
() => this.options.http3,
|
||||||
() => this.options.vpnConfig?.enabled ? (this.options.vpnConfig.subnet || '10.8.0.0/24') : undefined,
|
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);
|
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
||||||
await this.apiTokenManager.initialize();
|
await this.apiTokenManager.initialize();
|
||||||
@@ -664,9 +684,8 @@ export class DcRouter {
|
|||||||
if (this.vpnManager && this.options.vpnConfig?.enabled) {
|
if (this.vpnManager && this.options.vpnConfig?.enabled) {
|
||||||
const subnet = this.vpnManager.getSubnet();
|
const subnet = this.vpnManager.getSubnet();
|
||||||
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
|
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
|
||||||
const mode = this.vpnManager.forwardingMode;
|
|
||||||
const clientCount = this.vpnManager.listClients().length;
|
const clientCount = this.vpnManager.listClients().length;
|
||||||
logger.log('info', `VPN Service: mode=${mode}, subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote Ingress summary
|
// Remote Ingress summary
|
||||||
@@ -950,19 +969,14 @@ export class DcRouter {
|
|||||||
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// When VPN is in socket mode, the userspace NAT engine sends PP v2 headers
|
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||||
// on outbound connections to SmartProxy to preserve VPN client tunnel IPs.
|
|
||||||
if (this.options.vpnConfig?.enabled) {
|
if (this.options.vpnConfig?.enabled) {
|
||||||
const vpnForwardingMode = this.options.vpnConfig.forwardingMode
|
smartProxyConfig.acceptProxyProtocol = true;
|
||||||
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
|
if (!smartProxyConfig.proxyIPs) {
|
||||||
if (vpnForwardingMode === 'socket') {
|
smartProxyConfig.proxyIPs = [];
|
||||||
smartProxyConfig.acceptProxyProtocol = true;
|
}
|
||||||
if (!smartProxyConfig.proxyIPs) {
|
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||||
smartProxyConfig.proxyIPs = [];
|
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||||
}
|
|
||||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
|
||||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2085,7 +2099,12 @@ export class DcRouter {
|
|||||||
wgListenPort: this.options.vpnConfig.wgListenPort,
|
wgListenPort: this.options.vpnConfig.wgListenPort,
|
||||||
dns: this.options.vpnConfig.dns,
|
dns: this.options.vpnConfig.dns,
|
||||||
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
||||||
forwardingMode: this.options.vpnConfig.forwardingMode,
|
initialClients: this.options.vpnConfig.clients,
|
||||||
|
destinationPolicy: this.options.vpnConfig.destinationPolicy,
|
||||||
|
onClientChanged: () => {
|
||||||
|
// Re-apply routes so tag-based ipAllowLists get updated
|
||||||
|
this.routeConfigManager?.applyRoutes();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.vpnManager.start();
|
await this.vpnManager.start();
|
||||||
@@ -2104,11 +2123,23 @@ export class DcRouter {
|
|||||||
if (dcrouterRoute.vpn?.required) {
|
if (dcrouterRoute.vpn?.required) {
|
||||||
injectedCount++;
|
injectedCount++;
|
||||||
const existing = route.security?.ipAllowList || [];
|
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 {
|
return {
|
||||||
...route,
|
...route,
|
||||||
security: {
|
security: {
|
||||||
...route.security,
|
...route.security,
|
||||||
ipAllowList: [...existing, vpnSubnet],
|
ipAllowList: [...existing, ...vpnAllowList],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2116,7 +2147,7 @@ export class DcRouter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (injectedCount > 0) {
|
if (injectedCount > 0) {
|
||||||
logger.log('info', `VPN: Injected ipAllowList (${vpnSubnet}) into ${injectedCount} VPN-protected route(s)`);
|
logger.log('info', `VPN: Injected ipAllowList into ${injectedCount} VPN-protected route(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class RouteConfigManager {
|
|||||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||||
private getVpnSubnet?: () => string | undefined,
|
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -246,7 +246,7 @@ export class RouteConfigManager {
|
|||||||
// Private: apply merged routes to SmartProxy
|
// Private: apply merged routes to SmartProxy
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async applyRoutes(): Promise<void> {
|
public async applyRoutes(): Promise<void> {
|
||||||
const smartProxy = this.getSmartProxy();
|
const smartProxy = this.getSmartProxy();
|
||||||
if (!smartProxy) return;
|
if (!smartProxy) return;
|
||||||
|
|
||||||
@@ -262,9 +262,9 @@ export class RouteConfigManager {
|
|||||||
enabledRoutes.push(route);
|
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 http3Config = this.getHttp3Config?.();
|
||||||
const vpnSubnet = this.getVpnSubnet?.();
|
const vpnAllowList = this.getVpnAllowList;
|
||||||
for (const stored of this.storedRoutes.values()) {
|
for (const stored of this.storedRoutes.values()) {
|
||||||
if (stored.enabled) {
|
if (stored.enabled) {
|
||||||
let route = stored.route;
|
let route = stored.route;
|
||||||
@@ -272,15 +272,16 @@ export class RouteConfigManager {
|
|||||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||||
}
|
}
|
||||||
// Inject VPN security for programmatic routes with vpn.required
|
// Inject VPN security for programmatic routes with vpn.required
|
||||||
if (vpnSubnet) {
|
if (vpnAllowList) {
|
||||||
const dcRoute = route as IDcRouterRouteConfig;
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
if (dcRoute.vpn?.required) {
|
if (dcRoute.vpn?.required) {
|
||||||
const existing = route.security?.ipAllowList || [];
|
const existing = route.security?.ipAllowList || [];
|
||||||
|
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
|
||||||
route = {
|
route = {
|
||||||
...route,
|
...route,
|
||||||
security: {
|
security: {
|
||||||
...route.security,
|
...route.security,
|
||||||
ipAllowList: [...existing, vpnSubnet],
|
ipAllowList: [...existing, ...allowList],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class VpnHandler {
|
|||||||
const clients = manager.listClients().map((c) => ({
|
const clients = manager.listClients().map((c) => ({
|
||||||
clientId: c.clientId,
|
clientId: c.clientId,
|
||||||
enabled: c.enabled,
|
enabled: c.enabled,
|
||||||
tags: c.tags,
|
serverDefinedClientTags: c.serverDefinedClientTags,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
assignedIp: c.assignedIp,
|
assignedIp: c.assignedIp,
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
@@ -48,7 +48,6 @@ export class VpnHandler {
|
|||||||
return {
|
return {
|
||||||
status: {
|
status: {
|
||||||
running: false,
|
running: false,
|
||||||
forwardingMode: 'socket' as const,
|
|
||||||
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
||||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
serverPublicKeys: null,
|
serverPublicKeys: null,
|
||||||
@@ -62,7 +61,6 @@ export class VpnHandler {
|
|||||||
return {
|
return {
|
||||||
status: {
|
status: {
|
||||||
running: manager.running,
|
running: manager.running,
|
||||||
forwardingMode: manager.forwardingMode,
|
|
||||||
subnet: manager.getSubnet(),
|
subnet: manager.getSubnet(),
|
||||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
serverPublicKeys: manager.getServerPublicKeys(),
|
serverPublicKeys: manager.getServerPublicKeys(),
|
||||||
@@ -89,7 +87,7 @@ export class VpnHandler {
|
|||||||
try {
|
try {
|
||||||
const bundle = await manager.createClient({
|
const bundle = await manager.createClient({
|
||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
tags: dataArg.tags,
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
description: dataArg.description,
|
description: dataArg.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +96,7 @@ export class VpnHandler {
|
|||||||
client: {
|
client: {
|
||||||
clientId: bundle.entry.clientId,
|
clientId: bundle.entry.clientId,
|
||||||
enabled: bundle.entry.enabled ?? true,
|
enabled: bundle.entry.enabled ?? true,
|
||||||
tags: bundle.entry.tags,
|
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||||
description: bundle.entry.description,
|
description: bundle.entry.description,
|
||||||
assignedIp: bundle.entry.assignedIp,
|
assignedIp: bundle.entry.assignedIp,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|||||||
@@ -14,8 +14,21 @@ export interface IVpnManagerConfig {
|
|||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||||
serverEndpoint?: string;
|
serverEndpoint?: string;
|
||||||
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
||||||
forwardingMode?: 'tun' | 'socket';
|
initialClients?: Array<{
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
||||||
|
onClientChanged?: () => void;
|
||||||
|
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
|
||||||
|
destinationPolicy?: {
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
target?: string;
|
||||||
|
allowList?: string[];
|
||||||
|
blockList?: string[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IPersistedServerKeys {
|
interface IPersistedServerKeys {
|
||||||
@@ -28,7 +41,7 @@ interface IPersistedServerKeys {
|
|||||||
interface IPersistedClient {
|
interface IPersistedClient {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
assignedIp?: string;
|
assignedIp?: string;
|
||||||
noisePublicKey: string;
|
noisePublicKey: string;
|
||||||
@@ -36,6 +49,8 @@ interface IPersistedClient {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
/** @deprecated Legacy field — migrated to serverDefinedClientTags on load */
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,19 +63,10 @@ export class VpnManager {
|
|||||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||||
private clients: Map<string, IPersistedClient> = new Map();
|
private clients: Map<string, IPersistedClient> = new Map();
|
||||||
private serverKeys?: IPersistedServerKeys;
|
private serverKeys?: IPersistedServerKeys;
|
||||||
private _forwardingMode: 'tun' | 'socket';
|
|
||||||
|
|
||||||
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
|
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
|
||||||
this.storageManager = storageManager;
|
this.storageManager = storageManager;
|
||||||
this.config = config;
|
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. */
|
/** The VPN subnet CIDR. */
|
||||||
@@ -92,7 +98,7 @@ export class VpnManager {
|
|||||||
publicKey: client.noisePublicKey,
|
publicKey: client.noisePublicKey,
|
||||||
wgPublicKey: client.wgPublicKey,
|
wgPublicKey: client.wgPublicKey,
|
||||||
enabled: client.enabled,
|
enabled: client.enabled,
|
||||||
tags: client.tags,
|
serverDefinedClientTags: client.serverDefinedClientTags,
|
||||||
description: client.description,
|
description: client.description,
|
||||||
assignedIp: client.assignedIp,
|
assignedIp: client.assignedIp,
|
||||||
expiresAt: client.expiresAt,
|
expiresAt: client.expiresAt,
|
||||||
@@ -113,16 +119,33 @@ export class VpnManager {
|
|||||||
publicKey: this.serverKeys.noisePublicKey,
|
publicKey: this.serverKeys.noisePublicKey,
|
||||||
subnet,
|
subnet,
|
||||||
dns: this.config.dns,
|
dns: this.config.dns,
|
||||||
forwardingMode: this._forwardingMode,
|
forwardingMode: 'socket',
|
||||||
transportMode: 'all',
|
transportMode: 'all',
|
||||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||||
wgListenPort,
|
wgListenPort,
|
||||||
clients: clientEntries,
|
clients: clientEntries,
|
||||||
socketForwardProxyProtocol: this._forwardingMode === 'socket',
|
socketForwardProxyProtocol: true,
|
||||||
|
destinationPolicy: this.config.destinationPolicy
|
||||||
|
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.vpnServer.start(serverConfig);
|
await this.vpnServer.start(serverConfig);
|
||||||
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
|
||||||
|
// 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: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,7 +171,7 @@ export class VpnManager {
|
|||||||
*/
|
*/
|
||||||
public async createClient(opts: {
|
public async createClient(opts: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||||
if (!this.vpnServer) {
|
if (!this.vpnServer) {
|
||||||
@@ -157,7 +180,7 @@ export class VpnManager {
|
|||||||
|
|
||||||
const bundle = await this.vpnServer.createClient({
|
const bundle = await this.vpnServer.createClient({
|
||||||
clientId: opts.clientId,
|
clientId: opts.clientId,
|
||||||
tags: opts.tags,
|
serverDefinedClientTags: opts.serverDefinedClientTags,
|
||||||
description: opts.description,
|
description: opts.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,7 +197,7 @@ export class VpnManager {
|
|||||||
const persisted: IPersistedClient = {
|
const persisted: IPersistedClient = {
|
||||||
clientId: bundle.entry.clientId,
|
clientId: bundle.entry.clientId,
|
||||||
enabled: bundle.entry.enabled ?? true,
|
enabled: bundle.entry.enabled ?? true,
|
||||||
tags: bundle.entry.tags,
|
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||||
description: bundle.entry.description,
|
description: bundle.entry.description,
|
||||||
assignedIp: bundle.entry.assignedIp,
|
assignedIp: bundle.entry.assignedIp,
|
||||||
noisePublicKey: bundle.entry.publicKey,
|
noisePublicKey: bundle.entry.publicKey,
|
||||||
@@ -186,6 +209,7 @@ export class VpnManager {
|
|||||||
this.clients.set(persisted.clientId, persisted);
|
this.clients.set(persisted.clientId, persisted);
|
||||||
await this.persistClient(persisted);
|
await this.persistClient(persisted);
|
||||||
|
|
||||||
|
this.config.onClientChanged?.();
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +223,7 @@ export class VpnManager {
|
|||||||
await this.vpnServer.removeClient(clientId);
|
await this.vpnServer.removeClient(clientId);
|
||||||
this.clients.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
|
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,6 +245,7 @@ export class VpnManager {
|
|||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
}
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,6 +260,7 @@ export class VpnManager {
|
|||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
}
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,6 +310,22 @@ export class VpnManager {
|
|||||||
return config;
|
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 ───────────────────────────────────────────────
|
// ── Status and telemetry ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -364,6 +407,12 @@ export class VpnManager {
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const client = await this.storageManager.getJSON<IPersistedClient>(key);
|
const client = await this.storageManager.getJSON<IPersistedClient>(key);
|
||||||
if (client) {
|
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);
|
this.clients.set(client.clientId, client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface IRouteRemoteIngress {
|
|||||||
export interface IRouteVpn {
|
export interface IRouteVpn {
|
||||||
/** Whether this route requires VPN access */
|
/** Whether this route requires VPN access */
|
||||||
required: boolean;
|
required: boolean;
|
||||||
|
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
|
||||||
|
allowedServerDefinedClientTags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
export interface IVpnClient {
|
export interface IVpnClient {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
assignedIp?: string;
|
assignedIp?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -17,7 +17,6 @@ export interface IVpnClient {
|
|||||||
*/
|
*/
|
||||||
export interface IVpnServerStatus {
|
export interface IVpnServerStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
forwardingMode: 'tun' | 'socket';
|
|
||||||
subnet: string;
|
subnet: string;
|
||||||
wgListenPort: number;
|
wgListenPort: number;
|
||||||
serverPublicKeys: {
|
serverPublicKeys: {
|
||||||
|
|||||||
@@ -96,7 +96,15 @@ interface IIdentity {
|
|||||||
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
||||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
| `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 and optional `allowedServerDefinedClientTags` |
|
||||||
|
|
||||||
|
#### VPN Interfaces
|
||||||
|
| Interface | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
|
||||||
|
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
|
||||||
|
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
||||||
|
|
||||||
### Request Interfaces (`requests`)
|
### Request Interfaces (`requests`)
|
||||||
|
|
||||||
@@ -205,6 +213,19 @@ interface ICertificateInfo {
|
|||||||
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
||||||
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
|
| `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
|
#### 📡 RADIUS
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
|-----------|--------|-------------|
|
|-----------|--------|-------------|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
|||||||
request: {
|
request: {
|
||||||
identity: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.13.0',
|
version: '11.19.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -974,7 +974,7 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
|
|||||||
|
|
||||||
export const createVpnClientAction = vpnStatePart.createAction<{
|
export const createVpnClientAction = vpnStatePart.createAction<{
|
||||||
clientId: string;
|
clientId: string;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
@@ -988,7 +988,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
|
|||||||
const response = await request.fire({
|
const response = await request.fire({
|
||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
tags: dataArg.tags,
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
description: dataArg.description,
|
description: dataArg.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from './shared/css.js';
|
||||||
@@ -181,13 +182,14 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
value: status?.running ? 'Running' : 'Stopped',
|
value: status?.running ? 'Running' : 'Stopped',
|
||||||
icon: 'lucide:server',
|
icon: 'lucide:server',
|
||||||
description: status?.running ? `${status.forwardingMode} mode` : 'VPN server not running',
|
description: status?.running ? 'Active' : 'VPN server not running',
|
||||||
color: status?.running ? '#10b981' : '#ef4444',
|
color: status?.running ? '#10b981' : '#ef4444',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>VPN</ops-sectionheading>
|
<ops-sectionheading>VPN</ops-sectionheading>
|
||||||
|
<div class="vpnContainer">
|
||||||
|
|
||||||
${this.vpnState.newClientConfig ? html`
|
${this.vpnState.newClientConfig ? html`
|
||||||
<div class="configDialog">
|
<div class="configDialog">
|
||||||
@@ -220,7 +222,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
|
|
||||||
${status ? html`
|
${status ? html`
|
||||||
<div class="serverInfo">
|
<div class="serverInfo">
|
||||||
@@ -232,10 +234,6 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
<span class="infoLabel">WireGuard Port</span>
|
<span class="infoLabel">WireGuard Port</span>
|
||||||
<span class="infoValue">${status.wgListenPort}</span>
|
<span class="infoValue">${status.wgListenPort}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoItem">
|
|
||||||
<span class="infoLabel">Forwarding Mode</span>
|
|
||||||
<span class="infoValue">${status.forwardingMode}</span>
|
|
||||||
</div>
|
|
||||||
${status.serverPublicKeys ? html`
|
${status.serverPublicKeys ? html`
|
||||||
<div class="infoItem">
|
<div class="infoItem">
|
||||||
<span class="infoLabel">WG Public Key</span>
|
<span class="infoLabel">WG Public Key</span>
|
||||||
@@ -255,38 +253,192 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
? html`<span class="statusBadge enabled">enabled</span>`
|
? html`<span class="statusBadge enabled">enabled</span>`
|
||||||
: html`<span class="statusBadge disabled">disabled</span>`,
|
: html`<span class="statusBadge disabled">disabled</span>`,
|
||||||
'VPN IP': client.assignedIp || '-',
|
'VPN IP': client.assignedIp || '-',
|
||||||
'Tags': client.tags?.length
|
'Tags': client.serverDefinedClientTags?.length
|
||||||
? html`${client.tags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||||
: '-',
|
: '-',
|
||||||
'Description': client.description || '-',
|
'Description': client.description || '-',
|
||||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||||
})}
|
})}
|
||||||
.dataActions=${[
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Create Client',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header'],
|
||||||
|
actionFunc: async () => {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Create VPN Client',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
if (!data.clientId) return;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Toggle',
|
name: 'Toggle',
|
||||||
iconName: 'lucide:power',
|
iconName: 'lucide:power',
|
||||||
action: async (client: interfaces.data.IVpnClient) => {
|
type: ['contextmenu', 'inRow'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
enabled: !client.enabled,
|
enabled: !client.enabled,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Export Config',
|
||||||
|
iconName: 'lucide:download',
|
||||||
|
type: ['contextmenu', 'inRow'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
const exportConfig = async (format: 'wireguard' | 'smartvpn') => {
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ExportVpnClientConfig
|
||||||
|
>('/typedrequest', 'exportVpnClientConfig');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: appstate.loginStatePart.getState()!.identity!,
|
||||||
|
clientId: client.clientId,
|
||||||
|
format,
|
||||||
|
});
|
||||||
|
if (response.success && response.config) {
|
||||||
|
const ext = format === 'wireguard' ? 'conf' : 'json';
|
||||||
|
const blob = new Blob([response.config], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${client.clientId}.${ext}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
DeesToast.createAndShow({ message: `${format} config downloaded`, type: 'success', duration: 3000 });
|
||||||
|
} else {
|
||||||
|
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
DeesToast.createAndShow({ message: err.message || 'Export failed', type: 'error', duration: 5000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Export Config: ${client.clientId}`,
|
||||||
|
content: html`<p>Choose a config format to download.</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'WireGuard (.conf)',
|
||||||
|
iconName: 'lucide:shield',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
await exportConfig('wireguard');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SmartVPN (.json)',
|
||||||
|
iconName: 'lucide:braces',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
await exportConfig('smartvpn');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rotate Keys',
|
||||||
|
iconName: 'lucide:rotate-cw',
|
||||||
|
type: ['contextmenu'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Rotate Client Keys',
|
||||||
|
content: html`<p>Generate new keys for "${client.clientId}"? The old keys will be invalidated and the client will need the new config to reconnect.</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Rotate',
|
||||||
|
iconName: 'lucide:rotate-cw',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_RotateVpnClientKey
|
||||||
|
>('/typedrequest', 'rotateVpnClientKey');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: appstate.loginStatePart.getState()!.identity!,
|
||||||
|
clientId: client.clientId,
|
||||||
|
});
|
||||||
|
if (response.success && response.wireguardConfig) {
|
||||||
|
appstate.vpnStatePart.setState({
|
||||||
|
...appstate.vpnStatePart.getState()!,
|
||||||
|
newClientConfig: response.wireguardConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await modalArg.destroy();
|
||||||
|
} catch (err: any) {
|
||||||
|
DeesToast.createAndShow({ message: err.message || 'Rotate failed', type: 'error', duration: 5000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
action: async (client: interfaces.data.IVpnClient) => {
|
type: ['contextmenu'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
DeesModal.createAndShow({
|
DeesModal.createAndShow({
|
||||||
heading: 'Delete VPN Client',
|
heading: 'Delete VPN Client',
|
||||||
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
|
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
action: async (modal: any) => {
|
iconName: 'lucide:trash2',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
||||||
modal.destroy();
|
await modalArg.destroy();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -294,37 +446,8 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
.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 tags = 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,
|
|
||||||
tags,
|
|
||||||
});
|
|
||||||
modal.destroy();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
></dees-table>
|
></dees-table>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
|
||||||
- Enable/disable, edit, secret regeneration, and delete actions
|
- 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
|
### 📜 Log Viewer
|
||||||
- Real-time log streaming
|
- Real-time log streaming
|
||||||
- Filter by log level (error, warning, info, debug)
|
- Filter by log level (error, warning, info, debug)
|
||||||
@@ -100,6 +107,7 @@ ts_web/
|
|||||||
├── ops-view-emails.ts # Email queue management
|
├── ops-view-emails.ts # Email queue management
|
||||||
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
||||||
├── ops-view-remoteingress.ts # Remote ingress edge management
|
├── ops-view-remoteingress.ts # Remote ingress edge management
|
||||||
|
├── ops-view-vpn.ts # VPN client management
|
||||||
├── ops-view-logs.ts # Log viewer
|
├── ops-view-logs.ts # Log viewer
|
||||||
├── ops-view-routes.ts # Route & API token management
|
├── ops-view-routes.ts # Route & API token management
|
||||||
├── ops-view-config.ts # Configuration display
|
├── 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 |
|
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
|
||||||
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
||||||
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
||||||
|
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
|
||||||
|
|
||||||
### Tab Visibility Optimization
|
### Tab Visibility Optimization
|
||||||
|
|
||||||
@@ -173,6 +182,13 @@ regenerateRemoteIngressSecretAction(id) // New secret
|
|||||||
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
||||||
clearNewEdgeSecretAction() // Dismiss secret banner
|
clearNewEdgeSecretAction() // Dismiss secret banner
|
||||||
fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
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
|
### Client-Side Routing
|
||||||
@@ -187,6 +203,7 @@ fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
|||||||
/emails/security → Security incidents
|
/emails/security → Security incidents
|
||||||
/certificates → Certificate management
|
/certificates → Certificate management
|
||||||
/remoteingress → Remote ingress edge management
|
/remoteingress → Remote ingress edge management
|
||||||
|
/vpn → VPN client management
|
||||||
/routes → Route & API token management
|
/routes → Route & API token management
|
||||||
/logs → Log viewer
|
/logs → Log viewer
|
||||||
/configuration → System configuration
|
/configuration → System configuration
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
|
|||||||
|
|
||||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
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];
|
export type TValidView = typeof validViews[number];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user