Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 |
29
changelog.md
29
changelog.md
@@ -1,5 +1,34 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "11.15.0",
|
||||
"version": "11.19.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"@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/smartvpn": "1.14.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.9.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
|
||||
version: 3.0.9
|
||||
'@push.rocks/smartvpn':
|
||||
specifier: 1.13.0
|
||||
version: 1.13.0
|
||||
specifier: 1.14.0
|
||||
version: 1.14.0
|
||||
'@push.rocks/taskbuffer':
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
@@ -1330,8 +1330,8 @@ 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/smartvpn@1.14.0':
|
||||
resolution: {integrity: sha512-zJmHiuLwY4OEN4jBVrJf1hAXpfO9f6Bulq/v1DrB16nR7VgE82KNqRLt0Wi/9PCsAUfmVJTvOf4yirnjBrEWQg==}
|
||||
|
||||
'@push.rocks/smartwatch@6.4.0':
|
||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||
@@ -6562,7 +6562,7 @@ snapshots:
|
||||
'@types/semver': 7.7.1
|
||||
semver: 7.7.4
|
||||
|
||||
'@push.rocks/smartvpn@1.13.0':
|
||||
'@push.rocks/smartvpn@1.14.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
|
||||
116
readme.md
116
readme.md
@@ -77,10 +77,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
### 🔐 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
|
||||
- **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** — in socket mode, the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
||||
- **PROXY protocol v2** — 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
|
||||
@@ -261,7 +264,9 @@ const router = new DcRouter({
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.example.com',
|
||||
wgListenPort: 51820,
|
||||
clients: [
|
||||
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
|
||||
],
|
||||
},
|
||||
|
||||
// Persistent storage
|
||||
@@ -456,7 +461,17 @@ interface IDcRouterOptions {
|
||||
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)
|
||||
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) ────────────────────────────────────────────
|
||||
@@ -1014,17 +1029,33 @@ DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
### Two Operating Modes
|
||||
### Destination Policy
|
||||
|
||||
| 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 |
|
||||
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
|
||||
|
||||
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.
|
||||
```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
|
||||
|
||||
@@ -1032,26 +1063,47 @@ DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls
|
||||
const router = new DcRouter({
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
||||
wgListenPort: 51820, // WireGuard UDP port (default)
|
||||
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
|
||||
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: [
|
||||
// This route is VPN-only — non-VPN clients are blocked
|
||||
// 🔐 VPN-only: any VPN client can access
|
||||
{
|
||||
name: 'admin-panel',
|
||||
match: { domains: ['admin.example.com'], ports: [443] },
|
||||
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 }, // 🔐 Only VPN clients can access this
|
||||
vpn: { required: true },
|
||||
},
|
||||
// This route is public — anyone can access it
|
||||
// 🔐 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] },
|
||||
@@ -1066,17 +1118,29 @@ const router = new DcRouter({
|
||||
});
|
||||
```
|
||||
|
||||
### Client Management via OpsServer API
|
||||
### Client Tags
|
||||
|
||||
Once the VPN server is running, you can manage clients through the OpsServer dashboard or API:
|
||||
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** — re-export in WireGuard or SmartVPN format
|
||||
- **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 or QR code — no custom VPN software needed.
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.15.0',
|
||||
version: '11.19.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -206,14 +206,21 @@ export interface IDcRouterOptions {
|
||||
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;
|
||||
}>;
|
||||
/** 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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -677,9 +684,8 @@ export class DcRouter {
|
||||
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}`);
|
||||
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
||||
}
|
||||
|
||||
// Remote Ingress summary
|
||||
@@ -963,19 +969,14 @@ 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.
|
||||
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||
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');
|
||||
}
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
}
|
||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2098,8 +2099,8 @@ export class DcRouter {
|
||||
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,
|
||||
destinationPolicy: this.options.vpnConfig.destinationPolicy,
|
||||
onClientChanged: () => {
|
||||
// Re-apply routes so tag-based ipAllowLists get updated
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
|
||||
@@ -48,7 +48,6 @@ export class VpnHandler {
|
||||
return {
|
||||
status: {
|
||||
running: false,
|
||||
forwardingMode: 'socket' as const,
|
||||
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||
serverPublicKeys: null,
|
||||
@@ -62,7 +61,6 @@ export class VpnHandler {
|
||||
return {
|
||||
status: {
|
||||
running: manager.running,
|
||||
forwardingMode: manager.forwardingMode,
|
||||
subnet: manager.getSubnet(),
|
||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||
serverPublicKeys: manager.getServerPublicKeys(),
|
||||
|
||||
@@ -14,8 +14,6 @@ export interface IVpnManagerConfig {
|
||||
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;
|
||||
@@ -24,6 +22,13 @@ export interface IVpnManagerConfig {
|
||||
}>;
|
||||
/** 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 {
|
||||
@@ -58,19 +63,10 @@ export class VpnManager {
|
||||
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. */
|
||||
@@ -123,12 +119,14 @@ export class VpnManager {
|
||||
publicKey: this.serverKeys.noisePublicKey,
|
||||
subnet,
|
||||
dns: this.config.dns,
|
||||
forwardingMode: this._forwardingMode,
|
||||
forwardingMode: 'socket',
|
||||
transportMode: 'all',
|
||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||
wgListenPort,
|
||||
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);
|
||||
@@ -147,7 +145,7 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@ export interface IVpnClient {
|
||||
*/
|
||||
export interface IVpnServerStatus {
|
||||
running: boolean;
|
||||
forwardingMode: 'tun' | 'socket';
|
||||
subnet: string;
|
||||
wgListenPort: number;
|
||||
serverPublicKeys: {
|
||||
|
||||
@@ -97,13 +97,13 @@ interface IIdentity {
|
||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
||||
| `IRouteVpn` | Route-level VPN config: `required` flag to restrict access to VPN clients |
|
||||
| `IRouteVpn` | Route-level VPN config: `required` flag and optional `allowedServerDefinedClientTags` |
|
||||
|
||||
#### VPN Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `IVpnClient` | Client registration: clientId, enabled, tags, description, assignedIp, timestamps |
|
||||
| `IVpnServerStatus` | Server status: running, forwardingMode, subnet, wgListenPort, publicKeys, client counts |
|
||||
| `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`)
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.15.0',
|
||||
version: '11.19.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
@@ -181,13 +182,14 @@ export class OpsViewVpn extends DeesElement {
|
||||
type: 'text',
|
||||
value: status?.running ? 'Running' : 'Stopped',
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>VPN</ops-sectionheading>
|
||||
<div class="vpnContainer">
|
||||
|
||||
${this.vpnState.newClientConfig ? html`
|
||||
<div class="configDialog">
|
||||
@@ -220,7 +222,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
|
||||
${status ? html`
|
||||
<div class="serverInfo">
|
||||
@@ -232,10 +234,6 @@ export class OpsViewVpn extends DeesElement {
|
||||
<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>
|
||||
@@ -262,31 +260,185 @@ export class OpsViewVpn extends DeesElement {
|
||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
.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',
|
||||
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, {
|
||||
clientId: client.clientId,
|
||||
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',
|
||||
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');
|
||||
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: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||
{
|
||||
name: 'Delete',
|
||||
action: async (modal: any) => {
|
||||
iconName: 'lucide:trash2',
|
||||
action: async (modalArg: any) => {
|
||||
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 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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user