Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99b40fea3f | |||
| 6f72e4fdbc | |||
| fbe845cd8e | |||
| 31413d28be | |||
| cd286cede6 | |||
| 36a3060cce | |||
| d2b108317e | |||
| dcd75f5e47 | |||
| 3d443fa147 | |||
| 2efdd2f16b | |||
| ec0348a83c | |||
| 6c4adf70c7 | |||
| 29d6076355 | |||
| fa96a41e68 | |||
| 1ea38ed5d2 | |||
| 7209903d02 |
14
Dockerfile
14
Dockerfile
@@ -18,9 +18,17 @@ WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
|
||||
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||
ENV DCROUTER_HEAP_SIZE=512
|
||||
ENV UV_THREADPOOL_SIZE=16
|
||||
|
||||
RUN pnpm install -g @servezone/healthy
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["npm", "start"]
|
||||
LABEL org.opencontainers.image.title="dcrouter" \
|
||||
org.opencontainers.image.description="Multi-service datacenter gateway" \
|
||||
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
|
||||
|
||||
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
|
||||
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
|
||||
|
||||
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]
|
||||
|
||||
59
changelog.md
59
changelog.md
@@ -1,5 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-30 - 11.13.0 - feat(vpn)
|
||||
add VPN server management and route-based VPN access control
|
||||
|
||||
- introduces a VPN manager backed by @push.rocks/smartvpn with persisted server keys and client registrations
|
||||
- adds ops API handlers and typed request interfaces for VPN client lifecycle, status, config export, and telemetry
|
||||
- adds ops dashboard VPN view and application state for managing VPN clients from the web UI
|
||||
- supports vpn.required on routes by injecting VPN subnet allowlists into static and programmatic SmartProxy routes
|
||||
- configures SmartProxy to accept proxy protocol in VPN socket forwarding mode to preserve client tunnel IPs
|
||||
|
||||
## 2026-03-27 - 11.12.4 - fix(acme)
|
||||
use X509 certificate expiry when reporting ACME certificate validity
|
||||
|
||||
- Parse the actual X509 validTo value from the PEM public certificate and fall back to SmartAcme's stored expiry if parsing fails
|
||||
- Update reported certificate expiry data and event communication timestamps to use the verified validity date
|
||||
- Bump @push.rocks/smartacme to ^9.3.1 and @push.rocks/smartproxy to ^27.1.0
|
||||
|
||||
## 2026-03-27 - 11.12.3 - fix(dcrouter)
|
||||
re-trigger auto certificate provisioning after SmartAcme becomes ready
|
||||
|
||||
- clear certificate provisioning scheduler state before retrying startup-affected routes
|
||||
- use route updates to re-run certificate provisioning for all current auto-cert routes
|
||||
- remove the unused single-route domain lookup helper
|
||||
|
||||
## 2026-03-27 - 11.12.2 - fix(dcrouter)
|
||||
guard auto certificate reprovisioning against unnamed routes
|
||||
|
||||
- Only re-triggers certificate provisioning for auto-cert routes when a route name is present.
|
||||
- Prevents reprovision attempts from running with an undefined route name and reduces warning noise.
|
||||
|
||||
## 2026-03-27 - 11.12.1 - fix(dcrouter)
|
||||
retry auto certificate provisioning after SmartAcme becomes ready
|
||||
|
||||
- detects certificates that failed during startup before the DNS-01 provider was available
|
||||
- clears provisioning backoff and failed status for affected domains before retrying
|
||||
- re-triggers auto certificate provisioning for SmartProxy routes once SmartAcme is ready
|
||||
|
||||
## 2026-03-27 - 11.12.0 - feat(web-ui)
|
||||
pause dashboard polling, sockets, and chart updates when the tab is hidden
|
||||
|
||||
- replace interval-based auto-refresh with scheduled actions using visibility-aware auto-pause
|
||||
- disconnect and reconnect the TypedSocket on tab visibility changes to avoid background log buildup
|
||||
- batch pushed log entries per animation frame and add an in-flight refresh guard to reduce unnecessary re-renders and overlapping requests
|
||||
- update state subscriptions to use select() and document the new tab visibility optimization behavior
|
||||
- bump smartdb, smartproxy, smartstate, remoteingress, dees-element, and tstest dependencies
|
||||
|
||||
## 2026-03-26 - 11.11.0 - feat(docker,cache,proxy)
|
||||
improve container runtime defaults and add configurable connection limits
|
||||
|
||||
- replace the embedded cache backend integration from smartmongo LocalTsmDb to smartdb LocalSmartDb
|
||||
- add OCI container settings for heap size, threadpool size, expanded exposed ports, image metadata, and a direct node startup command
|
||||
- introduce startup checks for file descriptor limits and warn when container nofile limits are too low for production
|
||||
- set gateway-oriented SmartProxy default limits and allow max connections, per-IP connections, and rate limits to be configured through OCI environment variables
|
||||
|
||||
## 2026-03-26 - 11.10.7 - fix(sms)
|
||||
update sms service to use async ProjectInfo initialization
|
||||
|
||||
- Replace direct ProjectInfo construction with the async create() factory in the SMS service startup flow
|
||||
- Bump related dependencies including @push.rocks/projectinfo, @push.rocks/smartdata, @push.rocks/smartmongo, @serve.zone/remoteingress, and @git.zone/tstest
|
||||
|
||||
## 2026-03-26 - 11.10.6 - fix(typescript)
|
||||
tighten TypeScript null safety and error handling across backend and ops UI
|
||||
|
||||
|
||||
27
package.json
27
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "11.10.6",
|
||||
"version": "11.13.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||
"start": "(node ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.0",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
@@ -36,33 +36,34 @@
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.49.0",
|
||||
"@design.estate/dees-element": "^2.2.3",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.3.0",
|
||||
"@push.rocks/smartdata": "^7.1.2",
|
||||
"@push.rocks/smartacme": "^9.3.1",
|
||||
"@push.rocks/smartdata": "^7.1.3",
|
||||
"@push.rocks/smartdb": "^2.0.0",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartmta": "^5.3.1",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^26.2.4",
|
||||
"@push.rocks/smartproxy": "^27.1.0",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.2.1",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.12.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.9.0",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.14.2",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"lru-cache": "^11.2.7",
|
||||
"uuid": "^13.0.0"
|
||||
|
||||
984
pnpm-lock.yaml
generated
984
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
62
readme.md
62
readme.md
@@ -83,7 +83,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
### 💾 Persistent Storage & Caching
|
||||
- **Multiple storage backends**: filesystem, custom functions, or in-memory
|
||||
- **Embedded cache database** via smartdata + LocalTsmDb (MongoDB-compatible)
|
||||
- **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
|
||||
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
||||
|
||||
### 🖥️ OpsServer Dashboard
|
||||
@@ -93,6 +93,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||
- **Remote ingress management** with connection token generation and one-click copy
|
||||
- **Read-only configuration display** — DcRouter is configured through code
|
||||
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
|
||||
|
||||
### 🔧 Programmatic API Client
|
||||
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
|
||||
@@ -340,7 +341,7 @@ graph TB
|
||||
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
|
||||
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
|
||||
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
|
||||
| **CacheDb** | `@push.rocks/smartdata` | Embedded MongoDB-compatible database (LocalTsmDb) for persistent caching |
|
||||
| **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
|
||||
|
||||
### How It Works
|
||||
|
||||
@@ -1066,7 +1067,7 @@ Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, I
|
||||
|
||||
### Cache Database
|
||||
|
||||
An embedded MongoDB-compatible database (via smartdata + LocalTsmDb) for persistent caching with automatic TTL cleanup:
|
||||
An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
|
||||
|
||||
```typescript
|
||||
cacheConfig: {
|
||||
@@ -1406,37 +1407,58 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
|
||||
|
||||
## Docker / OCI Container Deployment
|
||||
|
||||
DcRouter ships with a `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
|
||||
DcRouter ships with a production-ready `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. The container image includes tini as PID 1 (via the base image), proper health checks, and configurable resource limits. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
|
||||
|
||||
### Running with Docker
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-e DCROUTER_MODE=OCI_CONTAINER \
|
||||
--ulimit nofile=65536:65536 \
|
||||
-e DCROUTER_TLS_EMAIL=admin@example.com \
|
||||
-e DCROUTER_PUBLIC_IP=203.0.113.1 \
|
||||
-e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \
|
||||
-e DCROUTER_DNS_SCOPES=example.com \
|
||||
-p 80:80 -p 443:443 -p 25:25 -p 53:53/udp -p 3000:3000 \
|
||||
-p 80:80 -p 443:443 -p 25:25 -p 587:587 -p 465:465 \
|
||||
-p 53:53/udp -p 3000:3000 -p 8443:8443 \
|
||||
code.foss.global/serve.zone/dcrouter:latest
|
||||
```
|
||||
|
||||
> ⚡ **Production tip:** Always set `--ulimit nofile=65536:65536` for production deployments. DcRouter will log a warning at startup if the file descriptor limit is below 65536.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DCROUTER_MODE` | Set to `OCI_CONTAINER` to enable container mode | `OCI_CONTAINER` |
|
||||
| `DCROUTER_CONFIG_PATH` | Path to a JSON config file (loaded as base, env vars override) | `/config/dcrouter.json` |
|
||||
| `DCROUTER_BASE_DIR` | Override base data directory | `/data/dcrouter` |
|
||||
| `DCROUTER_TLS_EMAIL` | ACME contact email | `admin@example.com` |
|
||||
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | `example.com` |
|
||||
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | `203.0.113.1` |
|
||||
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | `198.51.100.1,198.51.100.2` |
|
||||
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | `ns1.example.com,ns2.example.com` |
|
||||
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | `example.com,other.com` |
|
||||
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | `mail.example.com` |
|
||||
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | `25,587,465` |
|
||||
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` |
|
||||
| Variable | Description | Default | Example |
|
||||
|----------|-------------|---------|---------|
|
||||
| `DCROUTER_MODE` | Container mode (set automatically in image) | `OCI_CONTAINER` | — |
|
||||
| `DCROUTER_CONFIG_PATH` | Path to JSON config file (env vars override) | — | `/config/dcrouter.json` |
|
||||
| `DCROUTER_BASE_DIR` | Base data directory | `~/.serve.zone/dcrouter` | `/data/dcrouter` |
|
||||
| `DCROUTER_TLS_EMAIL` | ACME contact email | — | `admin@example.com` |
|
||||
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | — | `example.com` |
|
||||
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | — | `203.0.113.1` |
|
||||
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | — | `198.51.100.1,198.51.100.2` |
|
||||
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | — | `ns1.example.com,ns2.example.com` |
|
||||
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | — | `example.com,other.com` |
|
||||
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | — | `mail.example.com` |
|
||||
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | — | `25,587,465` |
|
||||
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | `false` |
|
||||
| `DCROUTER_HEAP_SIZE` | Node.js V8 heap size in MB | `512` | `1024` |
|
||||
| `DCROUTER_MAX_CONNECTIONS` | Global max concurrent connections | `50000` | `100000` |
|
||||
| `DCROUTER_MAX_CONNECTIONS_PER_IP` | Max connections per source IP | `100` | `200` |
|
||||
| `DCROUTER_CONNECTION_RATE_LIMIT` | Max new connections/min per IP | `600` | `1200` |
|
||||
|
||||
### Exposed Ports
|
||||
|
||||
The container exposes all service ports:
|
||||
|
||||
| Port(s) | Protocol | Service |
|
||||
|---------|----------|---------|
|
||||
| 80, 443 | TCP | HTTP/HTTPS (SmartProxy) |
|
||||
| 25, 587, 465 | TCP | SMTP, Submission, SMTPS |
|
||||
| 53 | TCP/UDP | DNS |
|
||||
| 1812, 1813 | UDP | RADIUS auth/acct |
|
||||
| 3000 | TCP | OpsServer dashboard |
|
||||
| 8443 | TCP | Remote ingress tunnels |
|
||||
| 29000–30000 | TCP | Dynamic port range |
|
||||
|
||||
### Building the Image
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.10.6',
|
||||
version: '11.13.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
26
ts/cache/classes.cachedb.ts
vendored
26
ts/cache/classes.cachedb.ts
vendored
@@ -15,15 +15,15 @@ export interface ICacheDbOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
||||
* CacheDb - Wrapper around LocalSmartDb and smartdata
|
||||
*
|
||||
* Provides persistent caching using smartdata as the ORM layer
|
||||
* and LocalTsmDb as the embedded database engine.
|
||||
* and LocalSmartDb as the embedded database engine.
|
||||
*/
|
||||
export class CacheDb {
|
||||
private static instance: CacheDb | null = null;
|
||||
|
||||
private localTsmDb!: plugins.smartmongo.LocalTsmDb;
|
||||
private localSmartDb!: plugins.smartdb.LocalSmartDb;
|
||||
private smartdataDb!: plugins.smartdata.SmartdataDb;
|
||||
private options: Required<ICacheDbOptions>;
|
||||
private isStarted: boolean = false;
|
||||
@@ -55,8 +55,8 @@ export class CacheDb {
|
||||
|
||||
/**
|
||||
* Start the cache database
|
||||
* - Initializes LocalTsmDb with file persistence
|
||||
* - Connects smartdata to the LocalTsmDb via Unix socket
|
||||
* - Initializes LocalSmartDb with file persistence
|
||||
* - Connects smartdata to the LocalSmartDb via Unix socket
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
@@ -68,16 +68,16 @@ export class CacheDb {
|
||||
// Ensure storage directory exists
|
||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||
|
||||
// Create LocalTsmDb instance
|
||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||
// Create LocalSmartDb instance
|
||||
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
|
||||
folderPath: this.options.storagePath,
|
||||
});
|
||||
|
||||
// Start LocalTsmDb and get connection info
|
||||
const connectionInfo = await this.localTsmDb.start();
|
||||
// Start LocalSmartDb and get connection info
|
||||
const connectionInfo = await this.localSmartDb.start();
|
||||
|
||||
if (this.options.debug) {
|
||||
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
|
||||
logger.log('debug', `LocalSmartDb started with URI: ${connectionInfo.connectionUri}`);
|
||||
}
|
||||
|
||||
// Initialize smartdata with the connection URI
|
||||
@@ -109,9 +109,9 @@ export class CacheDb {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
|
||||
// Stop LocalTsmDb
|
||||
if (this.localTsmDb) {
|
||||
await this.localTsmDb.stop();
|
||||
// Stop LocalSmartDb
|
||||
if (this.localSmartDb) {
|
||||
await this.localSmartDb.stop();
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js';
|
||||
import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
@@ -188,6 +189,26 @@ export interface IDcRouterOptions {
|
||||
keyPath?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* VPN server configuration.
|
||||
* Enables VPN-based access control: routes with vpn.required are only
|
||||
* accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports.
|
||||
*/
|
||||
vpnConfig?: {
|
||||
/** Enable VPN server (default: false) */
|
||||
enabled?: boolean;
|
||||
/** VPN subnet CIDR (default: '10.8.0.0/24') */
|
||||
subnet?: string;
|
||||
/** WireGuard UDP listen port (default: 51820) */
|
||||
wgListenPort?: number;
|
||||
/** DNS servers pushed to VPN clients */
|
||||
dns?: string[];
|
||||
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||
serverEndpoint?: string;
|
||||
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
||||
forwardingMode?: 'tun' | 'socket';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +247,9 @@ export class DcRouter {
|
||||
public remoteIngressManager?: RemoteIngressManager;
|
||||
public tunnelManager?: TunnelManager;
|
||||
|
||||
// VPN
|
||||
public vpnManager?: VpnManager;
|
||||
|
||||
// Programmatic config API
|
||||
public routeConfigManager?: RouteConfigManager;
|
||||
public apiTokenManager?: ApiTokenManager;
|
||||
@@ -388,6 +412,23 @@ export class DcRouter {
|
||||
await this.smartAcme.start();
|
||||
this.smartAcmeReady = true;
|
||||
logger.log('info', 'SmartAcme DNS-01 provider is now ready');
|
||||
|
||||
// Re-trigger certificate provisioning for all auto-cert routes.
|
||||
// During startup, certProvisionFunction returned 'http01' (SmartAcme not ready),
|
||||
// but Rust ACME is disabled when certProvisionFunction is set — so all domains
|
||||
// failed silently (SmartProxy doesn't emit certificate-failed for this path).
|
||||
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
|
||||
// which calls certProvisionFunction again — now with smartAcmeReady === true.
|
||||
if (this.smartProxy) {
|
||||
if (this.certProvisionScheduler) {
|
||||
this.certProvisionScheduler.clear();
|
||||
}
|
||||
const currentRoutes = this.smartProxy.routeManager.getRoutes();
|
||||
logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
|
||||
this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
|
||||
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.withStop(async () => {
|
||||
@@ -412,6 +453,7 @@ export class DcRouter {
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
() => this.options.vpnConfig?.enabled ? (this.options.vpnConfig.subnet || '10.8.0.0/24') : undefined,
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
||||
await this.apiTokenManager.initialize();
|
||||
@@ -516,6 +558,25 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// VPN Server: optional, depends on SmartProxy
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('VpnServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.withStart(async () => {
|
||||
await this.setupVpnServer();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.vpnManager) {
|
||||
await this.vpnManager.stop();
|
||||
this.vpnManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
|
||||
);
|
||||
}
|
||||
|
||||
// Wire up aggregated events for logging
|
||||
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
|
||||
const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info';
|
||||
@@ -528,10 +589,36 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
public async start() {
|
||||
await this.checkSystemLimits();
|
||||
logger.log('info', 'Starting DcRouter Services');
|
||||
await this.serviceManager.start();
|
||||
this.logStartupSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect OS-level resource limits and warn if they are too low for production use.
|
||||
* This is detection only — no attempts to raise limits.
|
||||
*/
|
||||
private async checkSystemLimits(): Promise<void> {
|
||||
try {
|
||||
const fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
|
||||
const limitsContent = await fs.file('/proc/self/limits').encoding('utf8').read() as string;
|
||||
const nofileLine = limitsContent.split('\n').find((line: string) => line.startsWith('Max open files'));
|
||||
if (nofileLine) {
|
||||
const parts = nofileLine.split(/\s{2,}/);
|
||||
const softLimit = parseInt(parts[1], 10);
|
||||
const hardLimit = parseInt(parts[2], 10);
|
||||
if (softLimit < 65536) {
|
||||
logger.log('warn', `File descriptor soft limit is ${softLimit} (hard: ${hardLimit}). ` +
|
||||
`For production use, set --ulimit nofile=65536:65536 on the container runtime.`);
|
||||
} else {
|
||||
logger.log('info', `File descriptor limits: soft=${softLimit}, hard=${hardLimit}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-Linux or /proc not available — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log comprehensive startup summary
|
||||
@@ -573,6 +660,15 @@ export class DcRouter {
|
||||
logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
// VPN summary
|
||||
if (this.vpnManager && this.options.vpnConfig?.enabled) {
|
||||
const subnet = this.vpnManager.getSubnet();
|
||||
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
|
||||
const mode = this.vpnManager.forwardingMode;
|
||||
const clientCount = this.vpnManager.listClients().length;
|
||||
logger.log('info', `VPN Service: mode=${mode}, subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
||||
}
|
||||
|
||||
// Remote Ingress summary
|
||||
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
||||
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
||||
@@ -698,6 +794,11 @@ export class DcRouter {
|
||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||
}
|
||||
|
||||
// VPN route security injection: restrict vpn.required routes to VPN subnet
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
routes = this.injectVpnSecurity(routes);
|
||||
}
|
||||
|
||||
// Cache constructor routes for RouteConfigManager
|
||||
this.constructorRoutes = [...routes];
|
||||
|
||||
@@ -708,9 +809,28 @@ export class DcRouter {
|
||||
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start
|
||||
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
|
||||
|
||||
// Create SmartProxy configuration
|
||||
// Create SmartProxy configuration with sensible gateway defaults.
|
||||
// User's smartProxyConfig overrides these defaults via spread.
|
||||
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
|
||||
// --- dcrouter gateway defaults ---
|
||||
maxConnectionsPerIP: 100,
|
||||
connectionRateLimitPerMinute: 600,
|
||||
socketTimeout: 120_000,
|
||||
inactivityTimeout: 120_000,
|
||||
keepAlive: true,
|
||||
noDelay: true,
|
||||
gracefulShutdownTimeout: 30_000,
|
||||
// --- user overrides ---
|
||||
...this.options.smartProxyConfig,
|
||||
// --- deep-merge defaults.security so user can override maxConnections ---
|
||||
defaults: {
|
||||
...this.options.smartProxyConfig?.defaults,
|
||||
security: {
|
||||
maxConnections: 50_000,
|
||||
...this.options.smartProxyConfig?.defaults?.security,
|
||||
},
|
||||
},
|
||||
// --- always set by dcrouter (after spread) ---
|
||||
routes,
|
||||
acme: acmeConfig,
|
||||
certStore: {
|
||||
@@ -790,14 +910,22 @@ export class DcRouter {
|
||||
const cert = await this.smartAcme!.getCertificateForDomain(domain, {
|
||||
includeWildcard: !isWildcardDomain,
|
||||
});
|
||||
if (cert.validUntil) {
|
||||
eventComms.setExpiryDate(new Date(cert.validUntil));
|
||||
// Parse real X509 expiry from PEM (defense-in-depth over SmartAcme's estimate)
|
||||
let realValidUntil = cert.validUntil;
|
||||
if (cert.publicKey) {
|
||||
try {
|
||||
const x509 = new plugins.crypto.X509Certificate(cert.publicKey);
|
||||
realValidUntil = new Date(x509.validTo).getTime();
|
||||
} catch { /* fallback to SmartAcme's value */ }
|
||||
}
|
||||
if (realValidUntil) {
|
||||
eventComms.setExpiryDate(new Date(realValidUntil));
|
||||
}
|
||||
const result = {
|
||||
id: cert.id,
|
||||
domainName: cert.domainName,
|
||||
created: cert.created,
|
||||
validUntil: cert.validUntil,
|
||||
validUntil: realValidUntil,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
csr: cert.csr,
|
||||
@@ -822,6 +950,22 @@ export class DcRouter {
|
||||
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
||||
}
|
||||
|
||||
// When VPN is in socket mode, the userspace NAT engine sends PP v2 headers
|
||||
// on outbound connections to SmartProxy to preserve VPN client tunnel IPs.
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
const vpnForwardingMode = this.options.vpnConfig.forwardingMode
|
||||
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
|
||||
if (vpnForwardingMode === 'socket') {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
}
|
||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create SmartProxy instance
|
||||
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
|
||||
|
||||
@@ -1085,23 +1229,6 @@ export class DcRouter {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first route name that matches a given domain
|
||||
*/
|
||||
private findRouteNameForDomain(domain: string): string | undefined {
|
||||
if (!this.smartProxy) return undefined;
|
||||
for (const route of this.smartProxy.routeManager.getRoutes()) {
|
||||
if (!route.match.domains || !route.name) continue;
|
||||
const routeDomains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
for (const pattern of routeDomains) {
|
||||
if (this.isDomainMatch(domain, pattern)) return route.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL route names that match a given domain
|
||||
*/
|
||||
@@ -1943,6 +2070,58 @@ export class DcRouter {
|
||||
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up VPN server for VPN-based route access control.
|
||||
*/
|
||||
private async setupVpnServer(): Promise<void> {
|
||||
if (!this.options.vpnConfig?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', 'Setting up VPN server...');
|
||||
|
||||
this.vpnManager = new VpnManager(this.storageManager, {
|
||||
subnet: this.options.vpnConfig.subnet,
|
||||
wgListenPort: this.options.vpnConfig.wgListenPort,
|
||||
dns: this.options.vpnConfig.dns,
|
||||
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
||||
forwardingMode: this.options.vpnConfig.forwardingMode,
|
||||
});
|
||||
|
||||
await this.vpnManager.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject VPN security into routes that have vpn.required === true.
|
||||
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
|
||||
*/
|
||||
private injectVpnSecurity(routes: plugins.smartproxy.IRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
|
||||
const vpnSubnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||
let injectedCount = 0;
|
||||
|
||||
const result = routes.map((route) => {
|
||||
const dcrouterRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
|
||||
if (dcrouterRoute.vpn?.required) {
|
||||
injectedCount++;
|
||||
const existing = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existing, vpnSubnet],
|
||||
},
|
||||
};
|
||||
}
|
||||
return route;
|
||||
});
|
||||
|
||||
if (injectedCount > 0) {
|
||||
logger.log('info', `VPN: Injected ipAllowList (${vpnSubnet}) into ${injectedCount} VPN-protected route(s)`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up RADIUS server for network authentication
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
|
||||
const ROUTES_PREFIX = '/config-api/routes/';
|
||||
@@ -22,6 +23,7 @@ export class RouteConfigManager {
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnSubnet?: () => string | undefined,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -262,13 +264,28 @@ export class RouteConfigManager {
|
||||
|
||||
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnSubnet = this.getVpnSubnet?.();
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config && http3Config.enabled !== false) {
|
||||
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
|
||||
} else {
|
||||
enabledRoutes.push(stored.route);
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
}
|
||||
// Inject VPN security for programmatic routes with vpn.required
|
||||
if (vpnSubnet) {
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (dcRoute.vpn?.required) {
|
||||
const existing = route.security?.ipAllowList || [];
|
||||
route = {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existing, vpnSubnet],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
enabledRoutes.push(route);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export class OpsServer {
|
||||
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||
private vpnHandler!: handlers.VpnHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -86,6 +87,7 @@ export class OpsServer {
|
||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||
this.vpnHandler = new handlers.VpnHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export * from './email-ops.handler.js';
|
||||
export * from './certificate.handler.js';
|
||||
export * from './remoteingress.handler.js';
|
||||
export * from './route-management.handler.js';
|
||||
export * from './api-token.handler.js';
|
||||
export * from './api-token.handler.js';
|
||||
export * from './vpn.handler.js';
|
||||
257
ts/opsserver/handlers/vpn.handler.ts
Normal file
257
ts/opsserver/handlers/vpn.handler.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class VpnHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
const viewRouter = this.opsServerRef.viewRouter;
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
|
||||
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||
|
||||
// Get all registered VPN clients
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
|
||||
'getVpnClients',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
if (!manager) {
|
||||
return { clients: [] };
|
||||
}
|
||||
const clients = manager.listClients().map((c) => ({
|
||||
clientId: c.clientId,
|
||||
enabled: c.enabled,
|
||||
tags: c.tags,
|
||||
description: c.description,
|
||||
assignedIp: c.assignedIp,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
expiresAt: c.expiresAt,
|
||||
}));
|
||||
return { clients };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get VPN server status
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
|
||||
'getVpnStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
|
||||
if (!manager) {
|
||||
return {
|
||||
status: {
|
||||
running: false,
|
||||
forwardingMode: 'socket' as const,
|
||||
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||
serverPublicKeys: null,
|
||||
registeredClients: 0,
|
||||
connectedClients: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const connected = await manager.getConnectedClients();
|
||||
return {
|
||||
status: {
|
||||
running: manager.running,
|
||||
forwardingMode: manager.forwardingMode,
|
||||
subnet: manager.getSubnet(),
|
||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||
serverPublicKeys: manager.getServerPublicKeys(),
|
||||
registeredClients: manager.listClients().length,
|
||||
connectedClients: connected.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||
|
||||
// Create a new VPN client
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
|
||||
'createVpnClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'VPN not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = await manager.createClient({
|
||||
clientId: dataArg.clientId,
|
||||
tags: dataArg.tags,
|
||||
description: dataArg.description,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
client: {
|
||||
clientId: bundle.entry.clientId,
|
||||
enabled: bundle.entry.enabled ?? true,
|
||||
tags: bundle.entry.tags,
|
||||
description: bundle.entry.description,
|
||||
assignedIp: bundle.entry.assignedIp,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: bundle.entry.expiresAt,
|
||||
},
|
||||
wireguardConfig: bundle.wireguardConfig,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete a VPN client
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
|
||||
'deleteVpnClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'VPN not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.removeClient(dataArg.clientId);
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Enable a VPN client
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
|
||||
'enableVpnClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'VPN not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.enableClient(dataArg.clientId);
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Disable a VPN client
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
|
||||
'disableVpnClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'VPN not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.disableClient(dataArg.clientId);
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Rotate a VPN client's keys
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
|
||||
'rotateVpnClientKey',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'VPN not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = await manager.rotateClientKey(dataArg.clientId);
|
||||
return {
|
||||
success: true,
|
||||
wireguardConfig: bundle.wireguardConfig,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Export a VPN client config
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
|
||||
'exportVpnClientConfig',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'VPN not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await manager.exportClientConfig(dataArg.clientId, dataArg.format);
|
||||
return { success: true, config };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get telemetry for a specific VPN client
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
|
||||
'getVpnClientTelemetry',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'VPN not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const telemetry = await manager.getClientTelemetry(dataArg.clientId);
|
||||
if (!telemetry) {
|
||||
return { success: false, message: 'Client not found or not connected' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
telemetry: {
|
||||
clientId: telemetry.clientId,
|
||||
assignedIp: telemetry.assignedIp,
|
||||
bytesSent: telemetry.bytesSent,
|
||||
bytesReceived: telemetry.bytesReceived,
|
||||
packetsDropped: telemetry.packetsDropped,
|
||||
bytesDropped: telemetry.bytesDropped,
|
||||
lastKeepaliveAt: telemetry.lastKeepaliveAt,
|
||||
keepalivesReceived: telemetry.keepalivesReceived,
|
||||
rateLimitBytesPerSec: telemetry.rateLimitBytesPerSec,
|
||||
burstBytes: telemetry.burstBytes,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,24 +47,25 @@ import * as qenv from '@push.rocks/qenv';
|
||||
import * as smartacme from '@push.rocks/smartacme';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
import * as smartdns from '@push.rocks/smartdns';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
import * as smartguard from '@push.rocks/smartguard';
|
||||
import * as smartjwt from '@push.rocks/smartjwt';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||
import * as smartmta from '@push.rocks/smartmta';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import * as smartdb from '@push.rocks/smartdb';
|
||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartproxy from '@push.rocks/smartproxy';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartvpn from '@push.rocks/smartvpn';
|
||||
import * as smartradius from '@push.rocks/smartradius';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer };
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
|
||||
|
||||
// Define SmartLog types for use in error handling
|
||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||
@@ -90,7 +91,7 @@ export {
|
||||
uuid,
|
||||
}
|
||||
|
||||
// Filesystem utilities (compatibility helpers for smartfile v13+)
|
||||
// Filesystem utilities
|
||||
export const fsUtils = {
|
||||
/**
|
||||
* Ensure a directory exists, creating it recursively if needed (sync)
|
||||
|
||||
@@ -30,7 +30,7 @@ export class SmsService {
|
||||
*/
|
||||
public async start() {
|
||||
logger.log('info', `starting sms service`);
|
||||
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
||||
this.projectinfo = await plugins.projectinfo.ProjectInfo.create(paths.packageDir);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendSms>(
|
||||
'sendSms',
|
||||
|
||||
378
ts/vpn/classes.vpn-manager.ts
Normal file
378
ts/vpn/classes.vpn-manager.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
||||
|
||||
const STORAGE_PREFIX_KEYS = '/vpn/server-keys';
|
||||
const STORAGE_PREFIX_CLIENTS = '/vpn/clients/';
|
||||
|
||||
export interface IVpnManagerConfig {
|
||||
/** VPN subnet CIDR (default: '10.8.0.0/24') */
|
||||
subnet?: string;
|
||||
/** WireGuard UDP listen port (default: 51820) */
|
||||
wgListenPort?: number;
|
||||
/** DNS servers pushed to VPN clients */
|
||||
dns?: string[];
|
||||
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||
serverEndpoint?: string;
|
||||
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
||||
forwardingMode?: 'tun' | 'socket';
|
||||
}
|
||||
|
||||
interface IPersistedServerKeys {
|
||||
noisePrivateKey: string;
|
||||
noisePublicKey: string;
|
||||
wgPrivateKey: string;
|
||||
wgPublicKey: string;
|
||||
}
|
||||
|
||||
interface IPersistedClient {
|
||||
clientId: string;
|
||||
enabled: boolean;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
assignedIp?: string;
|
||||
noisePublicKey: string;
|
||||
wgPublicKey: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the SmartVPN server lifecycle and VPN client CRUD.
|
||||
* Persists server keys and client registrations via StorageManager.
|
||||
*/
|
||||
export class VpnManager {
|
||||
private storageManager: StorageManager;
|
||||
private config: IVpnManagerConfig;
|
||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||
private clients: Map<string, IPersistedClient> = new Map();
|
||||
private serverKeys?: IPersistedServerKeys;
|
||||
private _forwardingMode: 'tun' | 'socket';
|
||||
|
||||
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
|
||||
this.storageManager = storageManager;
|
||||
this.config = config;
|
||||
// Auto-detect forwarding mode: tun if root, socket otherwise
|
||||
this._forwardingMode = config.forwardingMode
|
||||
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
|
||||
}
|
||||
|
||||
/** The effective forwarding mode (tun or socket). */
|
||||
public get forwardingMode(): 'tun' | 'socket' {
|
||||
return this._forwardingMode;
|
||||
}
|
||||
|
||||
/** The VPN subnet CIDR. */
|
||||
public getSubnet(): string {
|
||||
return this.config.subnet || '10.8.0.0/24';
|
||||
}
|
||||
|
||||
/** Whether the VPN server is running. */
|
||||
public get running(): boolean {
|
||||
return this.vpnServer?.running ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the VPN server.
|
||||
* Loads or generates server keys, loads persisted clients, starts VpnServer.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Load or generate server keys
|
||||
this.serverKeys = await this.loadOrGenerateServerKeys();
|
||||
|
||||
// Load persisted clients
|
||||
await this.loadPersistedClients();
|
||||
|
||||
// Build client entries for the daemon
|
||||
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
|
||||
for (const client of this.clients.values()) {
|
||||
clientEntries.push({
|
||||
clientId: client.clientId,
|
||||
publicKey: client.noisePublicKey,
|
||||
wgPublicKey: client.wgPublicKey,
|
||||
enabled: client.enabled,
|
||||
tags: client.tags,
|
||||
description: client.description,
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
const subnet = this.getSubnet();
|
||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||
|
||||
// Create and start VpnServer
|
||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||
transport: { transport: 'stdio' },
|
||||
});
|
||||
|
||||
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
|
||||
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
||||
privateKey: this.serverKeys.noisePrivateKey,
|
||||
publicKey: this.serverKeys.noisePublicKey,
|
||||
subnet,
|
||||
dns: this.config.dns,
|
||||
forwardingMode: this._forwardingMode,
|
||||
transportMode: 'all',
|
||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: this._forwardingMode === 'socket',
|
||||
};
|
||||
|
||||
await this.vpnServer.start(serverConfig);
|
||||
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the VPN server.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.vpnServer) {
|
||||
try {
|
||||
await this.vpnServer.stopServer();
|
||||
} catch {
|
||||
// Ignore stop errors
|
||||
}
|
||||
this.vpnServer.stop();
|
||||
this.vpnServer = undefined;
|
||||
}
|
||||
logger.log('info', 'VPN server stopped');
|
||||
}
|
||||
|
||||
// ── Client CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new VPN client. Returns the config bundle (secrets only shown once).
|
||||
*/
|
||||
public async createClient(opts: {
|
||||
clientId: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||
if (!this.vpnServer) {
|
||||
throw new Error('VPN server not running');
|
||||
}
|
||||
|
||||
const bundle = await this.vpnServer.createClient({
|
||||
clientId: opts.clientId,
|
||||
tags: opts.tags,
|
||||
description: opts.description,
|
||||
});
|
||||
|
||||
// Update WireGuard config endpoint if serverEndpoint is configured
|
||||
if (this.config.serverEndpoint && bundle.wireguardConfig) {
|
||||
const wgPort = this.config.wgListenPort ?? 51820;
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/Endpoint\s*=\s*.+/,
|
||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Persist client entry (without private keys)
|
||||
const persisted: IPersistedClient = {
|
||||
clientId: bundle.entry.clientId,
|
||||
enabled: bundle.entry.enabled ?? true,
|
||||
tags: bundle.entry.tags,
|
||||
description: bundle.entry.description,
|
||||
assignedIp: bundle.entry.assignedIp,
|
||||
noisePublicKey: bundle.entry.publicKey,
|
||||
wgPublicKey: bundle.entry.wgPublicKey || '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: bundle.entry.expiresAt,
|
||||
};
|
||||
this.clients.set(persisted.clientId, persisted);
|
||||
await this.persistClient(persisted);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a VPN client.
|
||||
*/
|
||||
public async removeClient(clientId: string): Promise<void> {
|
||||
if (!this.vpnServer) {
|
||||
throw new Error('VPN server not running');
|
||||
}
|
||||
await this.vpnServer.removeClient(clientId);
|
||||
this.clients.delete(clientId);
|
||||
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered clients (without secrets).
|
||||
*/
|
||||
public listClients(): IPersistedClient[] {
|
||||
return [...this.clients.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a client.
|
||||
*/
|
||||
public async enableClient(clientId: string): Promise<void> {
|
||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||
await this.vpnServer.enableClient(clientId);
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.enabled = true;
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a client.
|
||||
*/
|
||||
public async disableClient(clientId: string): Promise<void> {
|
||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||
await this.vpnServer.disableClient(clientId);
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.enabled = false;
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a client's keys. Returns the new config bundle.
|
||||
*/
|
||||
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
||||
|
||||
// Update endpoint in WireGuard config
|
||||
if (this.config.serverEndpoint && bundle.wireguardConfig) {
|
||||
const wgPort = this.config.wgListenPort ?? 51820;
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/Endpoint\s*=\s*.+/,
|
||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update persisted entry with new public keys
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.noisePublicKey = bundle.entry.publicKey;
|
||||
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a client config (without secrets).
|
||||
*/
|
||||
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||
let config = await this.vpnServer.exportClientConfig(clientId, format);
|
||||
|
||||
// Update endpoint in WireGuard config
|
||||
if (format === 'wireguard' && this.config.serverEndpoint) {
|
||||
const wgPort = this.config.wgListenPort ?? 51820;
|
||||
config = config.replace(
|
||||
/Endpoint\s*=\s*.+/,
|
||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// ── Status and telemetry ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get server status.
|
||||
*/
|
||||
public async getStatus(): Promise<plugins.smartvpn.IVpnStatus | null> {
|
||||
if (!this.vpnServer) return null;
|
||||
return this.vpnServer.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server statistics.
|
||||
*/
|
||||
public async getStatistics(): Promise<plugins.smartvpn.IVpnServerStatistics | null> {
|
||||
if (!this.vpnServer) return null;
|
||||
return this.vpnServer.getStatistics();
|
||||
}
|
||||
|
||||
/**
|
||||
* List currently connected clients.
|
||||
*/
|
||||
public async getConnectedClients(): Promise<plugins.smartvpn.IVpnClientInfo[]> {
|
||||
if (!this.vpnServer) return [];
|
||||
return this.vpnServer.listClients();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get telemetry for a specific client.
|
||||
*/
|
||||
public async getClientTelemetry(clientId: string): Promise<plugins.smartvpn.IVpnClientTelemetry | null> {
|
||||
if (!this.vpnServer) return null;
|
||||
return this.vpnServer.getClientTelemetry(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server public keys (for display/info).
|
||||
*/
|
||||
public getServerPublicKeys(): { noisePublicKey: string; wgPublicKey: string } | null {
|
||||
if (!this.serverKeys) return null;
|
||||
return {
|
||||
noisePublicKey: this.serverKeys.noisePublicKey,
|
||||
wgPublicKey: this.serverKeys.wgPublicKey,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async loadOrGenerateServerKeys(): Promise<IPersistedServerKeys> {
|
||||
const stored = await this.storageManager.getJSON<IPersistedServerKeys>(STORAGE_PREFIX_KEYS);
|
||||
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
|
||||
logger.log('info', 'Loaded VPN server keys from storage');
|
||||
return stored;
|
||||
}
|
||||
|
||||
// Generate new keys via the daemon
|
||||
const tempServer = new plugins.smartvpn.VpnServer({
|
||||
transport: { transport: 'stdio' },
|
||||
});
|
||||
await tempServer.start();
|
||||
|
||||
const noiseKeys = await tempServer.generateKeypair();
|
||||
const wgKeys = await tempServer.generateWgKeypair();
|
||||
tempServer.stop();
|
||||
|
||||
const keys: IPersistedServerKeys = {
|
||||
noisePrivateKey: noiseKeys.privateKey,
|
||||
noisePublicKey: noiseKeys.publicKey,
|
||||
wgPrivateKey: wgKeys.privateKey,
|
||||
wgPublicKey: wgKeys.publicKey,
|
||||
};
|
||||
|
||||
await this.storageManager.setJSON(STORAGE_PREFIX_KEYS, keys);
|
||||
logger.log('info', 'Generated and persisted new VPN server keys');
|
||||
return keys;
|
||||
}
|
||||
|
||||
private async loadPersistedClients(): Promise<void> {
|
||||
const keys = await this.storageManager.list(STORAGE_PREFIX_CLIENTS);
|
||||
for (const key of keys) {
|
||||
const client = await this.storageManager.getJSON<IPersistedClient>(key);
|
||||
if (client) {
|
||||
this.clients.set(client.clientId, client);
|
||||
}
|
||||
}
|
||||
if (this.clients.size > 0) {
|
||||
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistClient(client: IPersistedClient): Promise<void> {
|
||||
await this.storageManager.setJSON(`${STORAGE_PREFIX_CLIENTS}${client.clientId}`, client);
|
||||
}
|
||||
}
|
||||
1
ts/vpn/index.ts
Normal file
1
ts/vpn/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.vpn-manager.js';
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './auth.js';
|
||||
export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './route-management.js';
|
||||
export * from './vpn.js';
|
||||
@@ -51,11 +51,21 @@ export interface IRouteRemoteIngress {
|
||||
edgeFilter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level VPN access configuration.
|
||||
* When attached to a route, restricts access to VPN clients only.
|
||||
*/
|
||||
export interface IRouteVpn {
|
||||
/** Whether this route requires VPN access */
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended route config used within dcrouter.
|
||||
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
|
||||
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
|
||||
* SmartProxy ignores unknown properties at runtime.
|
||||
*/
|
||||
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||
remoteIngress?: IRouteRemoteIngress;
|
||||
vpn?: IRouteVpn;
|
||||
};
|
||||
|
||||
45
ts_interfaces/data/vpn.ts
Normal file
45
ts_interfaces/data/vpn.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* A registered VPN client (secrets excluded from API responses).
|
||||
*/
|
||||
export interface IVpnClient {
|
||||
clientId: string;
|
||||
enabled: boolean;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
assignedIp?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* VPN server status.
|
||||
*/
|
||||
export interface IVpnServerStatus {
|
||||
running: boolean;
|
||||
forwardingMode: 'tun' | 'socket';
|
||||
subnet: string;
|
||||
wgListenPort: number;
|
||||
serverPublicKeys: {
|
||||
noisePublicKey: string;
|
||||
wgPublicKey: string;
|
||||
} | null;
|
||||
registeredClients: number;
|
||||
connectedClients: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* VPN client telemetry data.
|
||||
*/
|
||||
export interface IVpnClientTelemetry {
|
||||
clientId: string;
|
||||
assignedIp: string;
|
||||
bytesSent: number;
|
||||
bytesReceived: number;
|
||||
packetsDropped: number;
|
||||
bytesDropped: number;
|
||||
lastKeepaliveAt?: string;
|
||||
keepalivesReceived: number;
|
||||
rateLimitBytesPerSec?: number;
|
||||
burstBytes?: number;
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export * from './email-ops.js';
|
||||
export * from './certificate.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './api-tokens.js';
|
||||
export * from './api-tokens.js';
|
||||
export * from './vpn.js';
|
||||
175
ts_interfaces/requests/vpn.ts
Normal file
175
ts_interfaces/requests/vpn.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry } from '../data/vpn.js';
|
||||
|
||||
// ============================================================================
|
||||
// VPN Client Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all registered VPN clients.
|
||||
*/
|
||||
export interface IReq_GetVpnClients extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetVpnClients
|
||||
> {
|
||||
method: 'getVpnClients';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
clients: IVpnClient[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VPN server status.
|
||||
*/
|
||||
export interface IReq_GetVpnStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetVpnStatus
|
||||
> {
|
||||
method: 'getVpnStatus';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
status: IVpnServerStatus;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new VPN client. Returns the config bundle (secrets only shown once).
|
||||
*/
|
||||
export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateVpnClient
|
||||
> {
|
||||
method: 'createVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
client?: IVpnClient;
|
||||
/** WireGuard .conf file content (only returned at creation) */
|
||||
wireguardConfig?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a VPN client.
|
||||
*/
|
||||
export interface IReq_DeleteVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteVpnClient
|
||||
> {
|
||||
method: 'deleteVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a VPN client.
|
||||
*/
|
||||
export interface IReq_EnableVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_EnableVpnClient
|
||||
> {
|
||||
method: 'enableVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a VPN client.
|
||||
*/
|
||||
export interface IReq_DisableVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DisableVpnClient
|
||||
> {
|
||||
method: 'disableVpnClient';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a VPN client's keys. Returns the new config bundle.
|
||||
*/
|
||||
export interface IReq_RotateVpnClientKey extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RotateVpnClientKey
|
||||
> {
|
||||
method: 'rotateVpnClientKey';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
/** WireGuard .conf file content with new keys */
|
||||
wireguardConfig?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a VPN client config.
|
||||
*/
|
||||
export interface IReq_ExportVpnClientConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ExportVpnClientConfig
|
||||
> {
|
||||
method: 'exportVpnClientConfig';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
format: 'smartvpn' | 'wireguard';
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
config?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get telemetry for a specific VPN client.
|
||||
*/
|
||||
export interface IReq_GetVpnClientTelemetry extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetVpnClientTelemetry
|
||||
> {
|
||||
method: 'getVpnClientTelemetry';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
telemetry?: IVpnClientTelemetry;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -96,5 +96,27 @@ export function getOciContainerConfig(): IDcRouterOptions {
|
||||
};
|
||||
}
|
||||
|
||||
// Connection capacity config
|
||||
const maxConnections = process.env.DCROUTER_MAX_CONNECTIONS;
|
||||
const maxConnectionsPerIP = process.env.DCROUTER_MAX_CONNECTIONS_PER_IP;
|
||||
const connectionRateLimit = process.env.DCROUTER_CONNECTION_RATE_LIMIT;
|
||||
if (maxConnections || maxConnectionsPerIP || connectionRateLimit) {
|
||||
options.smartProxyConfig = {
|
||||
...options.smartProxyConfig,
|
||||
routes: options.smartProxyConfig?.routes || [],
|
||||
...(maxConnectionsPerIP ? { maxConnectionsPerIP: parseInt(maxConnectionsPerIP, 10) } : {}),
|
||||
...(connectionRateLimit ? { connectionRateLimitPerMinute: parseInt(connectionRateLimit, 10) } : {}),
|
||||
...(maxConnections ? {
|
||||
defaults: {
|
||||
...options.smartProxyConfig?.defaults,
|
||||
security: {
|
||||
...options.smartProxyConfig?.defaults?.security,
|
||||
maxConnections: parseInt(maxConnections, 10),
|
||||
},
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.10.6',
|
||||
version: '11.13.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -905,6 +905,161 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// VPN State
|
||||
// ============================================================================
|
||||
|
||||
export interface IVpnState {
|
||||
clients: interfaces.data.IVpnClient[];
|
||||
status: interfaces.data.IVpnServerStatus | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
/** WireGuard config shown after create/rotate (only shown once) */
|
||||
newClientConfig: string | null;
|
||||
}
|
||||
|
||||
export const vpnStatePart = await appState.getStatePart<IVpnState>(
|
||||
'vpn',
|
||||
{
|
||||
clients: [],
|
||||
status: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
newClientConfig: null,
|
||||
},
|
||||
'soft'
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// VPN Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const clientsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetVpnClients
|
||||
>('/typedrequest', 'getVpnClients');
|
||||
|
||||
const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetVpnStatus
|
||||
>('/typedrequest', 'getVpnStatus');
|
||||
|
||||
const [clientsResponse, statusResponse] = await Promise.all([
|
||||
clientsRequest.fire({ identity: context.identity }),
|
||||
statusRequest.fire({ identity: context.identity }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
clients: clientsResponse.clients,
|
||||
status: statusResponse.status,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch VPN data',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const createVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateVpnClient
|
||||
>('/typedrequest', 'createVpnClient');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
clientId: dataArg.clientId,
|
||||
tags: dataArg.tags,
|
||||
description: dataArg.description,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
return { ...currentState, error: response.message || 'Failed to create client' };
|
||||
}
|
||||
|
||||
const refreshed = await actionContext!.dispatch(fetchVpnAction, null);
|
||||
return {
|
||||
...refreshed,
|
||||
newClientConfig: response.wireguardConfig || null,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to create VPN client',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteVpnClientAction = vpnStatePart.createAction<string>(
|
||||
async (statePartArg, clientId, actionContext): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteVpnClient
|
||||
>('/typedrequest', 'deleteVpnClient');
|
||||
|
||||
await request.fire({ identity: context.identity!, clientId });
|
||||
return await actionContext!.dispatch(fetchVpnAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete VPN client',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const toggleVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
enabled: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
try {
|
||||
const method = dataArg.enabled ? 'enableVpnClient' : 'disableVpnClient';
|
||||
type TReq = interfaces.requests.IReq_EnableVpnClient | interfaces.requests.IReq_DisableVpnClient;
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<TReq>(
|
||||
'/typedrequest', method,
|
||||
);
|
||||
|
||||
await request.fire({ identity: context.identity!, clientId: dataArg.clientId });
|
||||
return await actionContext!.dispatch(fetchVpnAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to toggle VPN client',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const clearNewClientConfigAction = vpnStatePart.createAction(
|
||||
async (statePartArg): Promise<IVpnState> => {
|
||||
return { ...statePartArg.getState()!, newClientConfig: null };
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Actions
|
||||
// ============================================================================
|
||||
@@ -1186,18 +1341,33 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
||||
let socketClient: plugins.typedsocket.TypedSocket | null = null;
|
||||
const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Batched log entry handler — buffers incoming entries and flushes once per animation frame
|
||||
let logEntryBuffer: interfaces.data.ILogEntry[] = [];
|
||||
let logFlushScheduled = false;
|
||||
|
||||
function flushLogEntries() {
|
||||
logFlushScheduled = false;
|
||||
if (logEntryBuffer.length === 0) return;
|
||||
const current = logStatePart.getState()!;
|
||||
const updated = [...current.recentLogs, ...logEntryBuffer];
|
||||
logEntryBuffer = [];
|
||||
// Cap at 2000 entries
|
||||
if (updated.length > 2000) {
|
||||
updated.splice(0, updated.length - 2000);
|
||||
}
|
||||
logStatePart.setState({ ...current, recentLogs: updated } as ILogState);
|
||||
}
|
||||
|
||||
// Register handler for pushed log entries from the server
|
||||
socketRouter.addTypedHandler(
|
||||
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushLogEntry>(
|
||||
'pushLogEntry',
|
||||
async (dataArg) => {
|
||||
const current = logStatePart.getState()!;
|
||||
const updated = [...current.recentLogs, dataArg.entry];
|
||||
// Cap at 2000 entries
|
||||
if (updated.length > 2000) {
|
||||
updated.splice(0, updated.length - 2000);
|
||||
logEntryBuffer.push(dataArg.entry);
|
||||
if (!logFlushScheduled) {
|
||||
logFlushScheduled = true;
|
||||
requestAnimationFrame(flushLogEntries);
|
||||
}
|
||||
logStatePart.setState({ ...current, recentLogs: updated } as ILogState);
|
||||
return {};
|
||||
}
|
||||
)
|
||||
@@ -1228,8 +1398,21 @@ async function disconnectSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
// In-flight guard to prevent concurrent refresh requests
|
||||
let isRefreshing = false;
|
||||
|
||||
// Combined refresh action for efficient polling
|
||||
async function dispatchCombinedRefreshAction() {
|
||||
if (isRefreshing) return;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
await dispatchCombinedRefreshActionInner();
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchCombinedRefreshActionInner() {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return;
|
||||
const currentView = uiStatePart.getState()!.activeView;
|
||||
@@ -1344,6 +1527,15 @@ async function dispatchCombinedRefreshAction() {
|
||||
console.error('Remote ingress refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh VPN data if on vpn view
|
||||
if (currentView === 'vpn') {
|
||||
try {
|
||||
await vpnStatePart.dispatchAction(fetchVpnAction, null);
|
||||
} catch (error) {
|
||||
console.error('VPN refresh failed:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Combined refresh failed:', error);
|
||||
// If the error looks like an auth failure (invalid JWT), force re-login
|
||||
@@ -1355,48 +1547,48 @@ async function dispatchCombinedRefreshAction() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize auto-refresh
|
||||
let refreshInterval: NodeJS.Timeout | null = null;
|
||||
let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessary restarts
|
||||
// Create a proper action for the combined refresh so we can use createScheduledAction
|
||||
const combinedRefreshAction = statsStatePart.createAction<void>(async (statePartArg) => {
|
||||
await dispatchCombinedRefreshAction();
|
||||
// Return current state — dispatchCombinedRefreshAction already updates all state parts directly
|
||||
return statePartArg.getState()!;
|
||||
});
|
||||
|
||||
// Initialize auto-refresh when UI state is ready
|
||||
(() => {
|
||||
const startAutoRefresh = () => {
|
||||
const uiState = uiStatePart.getState()!;
|
||||
const loginState = loginStatePart.getState()!;
|
||||
// Scheduled refresh process with autoPause: 'visibility' — automatically pauses when tab is hidden
|
||||
let refreshProcess: ReturnType<typeof statsStatePart.createScheduledAction> | null = null;
|
||||
|
||||
// Only start if conditions are met and not already running at the same rate
|
||||
if (uiState.autoRefresh && loginState.isLoggedIn) {
|
||||
// Check if we need to restart the interval (rate changed or not running)
|
||||
if (!refreshInterval || currentRefreshRate !== uiState.refreshInterval) {
|
||||
stopAutoRefresh();
|
||||
currentRefreshRate = uiState.refreshInterval;
|
||||
refreshInterval = setInterval(() => {
|
||||
// Use combined refresh action for efficiency
|
||||
dispatchCombinedRefreshAction();
|
||||
}, uiState.refreshInterval);
|
||||
}
|
||||
} else {
|
||||
stopAutoRefresh();
|
||||
const startAutoRefresh = () => {
|
||||
const uiState = uiStatePart.getState()!;
|
||||
const loginState = loginStatePart.getState()!;
|
||||
|
||||
if (uiState.autoRefresh && loginState.isLoggedIn) {
|
||||
// Dispose old process if interval changed or not running
|
||||
if (refreshProcess) {
|
||||
refreshProcess.dispose();
|
||||
refreshProcess = null;
|
||||
}
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
currentRefreshRate = 0;
|
||||
refreshProcess = statsStatePart.createScheduledAction({
|
||||
action: combinedRefreshAction,
|
||||
payload: undefined,
|
||||
intervalMs: uiState.refreshInterval,
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
} else {
|
||||
if (refreshProcess) {
|
||||
refreshProcess.dispose();
|
||||
refreshProcess = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for relevant changes only
|
||||
let previousAutoRefresh = uiStatePart.getState()!.autoRefresh;
|
||||
let previousRefreshInterval = uiStatePart.getState()!.refreshInterval;
|
||||
let previousIsLoggedIn = loginStatePart.getState()!.isLoggedIn;
|
||||
|
||||
uiStatePart.state.subscribe((state) => {
|
||||
// Only restart if relevant values changed
|
||||
if (state.autoRefresh !== previousAutoRefresh ||
|
||||
// Watch for relevant changes
|
||||
let previousAutoRefresh = uiStatePart.getState()!.autoRefresh;
|
||||
let previousRefreshInterval = uiStatePart.getState()!.refreshInterval;
|
||||
let previousIsLoggedIn = loginStatePart.getState()!.isLoggedIn;
|
||||
|
||||
uiStatePart.select((s) => ({ autoRefresh: s.autoRefresh, refreshInterval: s.refreshInterval }))
|
||||
.subscribe((state) => {
|
||||
if (state.autoRefresh !== previousAutoRefresh ||
|
||||
state.refreshInterval !== previousRefreshInterval) {
|
||||
previousAutoRefresh = state.autoRefresh;
|
||||
previousRefreshInterval = state.refreshInterval;
|
||||
@@ -1404,26 +1596,33 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar
|
||||
}
|
||||
});
|
||||
|
||||
loginStatePart.state.subscribe((state) => {
|
||||
// Only restart if login state changed
|
||||
if (state.isLoggedIn !== previousIsLoggedIn) {
|
||||
previousIsLoggedIn = state.isLoggedIn;
|
||||
startAutoRefresh();
|
||||
loginStatePart.select((s) => s.isLoggedIn).subscribe((isLoggedIn) => {
|
||||
if (isLoggedIn !== previousIsLoggedIn) {
|
||||
previousIsLoggedIn = isLoggedIn;
|
||||
startAutoRefresh();
|
||||
|
||||
// Connect/disconnect TypedSocket based on login state
|
||||
if (state.isLoggedIn) {
|
||||
connectSocket();
|
||||
} else {
|
||||
disconnectSocket();
|
||||
}
|
||||
// Connect/disconnect TypedSocket based on login state
|
||||
if (isLoggedIn) {
|
||||
connectSocket();
|
||||
} else {
|
||||
disconnectSocket();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initial start
|
||||
startAutoRefresh();
|
||||
|
||||
// Connect TypedSocket if already logged in (e.g., persistent session)
|
||||
if (loginStatePart.getState()!.isLoggedIn) {
|
||||
// Pause/resume WebSocket when tab visibility changes
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
disconnectSocket();
|
||||
} else if (loginStatePart.getState()!.isLoggedIn) {
|
||||
connectSocket();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Initial start
|
||||
startAutoRefresh();
|
||||
|
||||
// Connect TypedSocket if already logged in (e.g., persistent session)
|
||||
if (loginStatePart.getState()!.isLoggedIn) {
|
||||
connectSocket();
|
||||
}
|
||||
@@ -9,4 +9,5 @@ export * from './ops-view-apitokens.js';
|
||||
export * from './ops-view-security.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './ops-view-vpn.js';
|
||||
export * from './shared/index.js';
|
||||
@@ -24,6 +24,7 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js';
|
||||
import { OpsViewSecurity } from './ops-view-security.js';
|
||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||
|
||||
@customElement('ops-dashboard')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement {
|
||||
iconName: 'lucide:globe',
|
||||
element: OpsViewRemoteIngress,
|
||||
},
|
||||
{
|
||||
name: 'VPN',
|
||||
iconName: 'lucide:shield',
|
||||
element: OpsViewVpn,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,7 @@ export class OpsViewCertificates extends DeesElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.certificateStatePart.state.subscribe((newState) => {
|
||||
const sub = appstate.certificateStatePart.select().subscribe((newState) => {
|
||||
this.certState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
@@ -28,7 +28,7 @@ export class OpsViewEmails extends DeesElement {
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
|
||||
this.stateSubscription = appstate.emailOpsStatePart.select().subscribe((state) => {
|
||||
this.emails = state.emails;
|
||||
this.isLoading = state.isLoading;
|
||||
});
|
||||
|
||||
@@ -47,10 +47,11 @@ export class OpsViewNetwork extends DeesElement {
|
||||
// Track if we need to update the chart to avoid unnecessary re-renders
|
||||
private lastChartUpdate = 0;
|
||||
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
|
||||
|
||||
|
||||
private trafficUpdateTimer: any = null;
|
||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||
private historyLoaded = false; // Whether server-side throughput history has been loaded
|
||||
private visibilityHandler: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -59,28 +60,42 @@ export class OpsViewNetwork extends DeesElement {
|
||||
this.updateNetworkData();
|
||||
this.startTrafficUpdateTimer();
|
||||
}
|
||||
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
|
||||
|
||||
// Pause/resume traffic timer when tab visibility changes
|
||||
this.visibilityHandler = () => {
|
||||
if (document.hidden) {
|
||||
this.stopTrafficUpdateTimer();
|
||||
} else {
|
||||
this.startTrafficUpdateTimer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||||
|
||||
// When network view becomes visible, ensure we fetch network data
|
||||
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
|
||||
}
|
||||
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
this.stopTrafficUpdateTimer();
|
||||
if (this.visibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||
this.visibilityHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToStateParts() {
|
||||
// Subscribe and track unsubscribe functions
|
||||
const statsUnsubscribe = appstate.statsStatePart.state.subscribe((state) => {
|
||||
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
|
||||
this.statsState = state;
|
||||
this.updateNetworkData();
|
||||
});
|
||||
this.rxSubscriptions.push(statsUnsubscribe);
|
||||
|
||||
const networkUnsubscribe = appstate.networkStatePart.state.subscribe((state) => {
|
||||
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
|
||||
this.networkState = state;
|
||||
this.updateNetworkData();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => {
|
||||
const sub = appstate.remoteIngressStatePart.select().subscribe((newState) => {
|
||||
this.riState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
330
ts_web/elements/ops-view-vpn.ts
Normal file
330
ts_web/elements/ops-view-vpn.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
DeesElement,
|
||||
html,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
css,
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-vpn': OpsViewVpn;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-vpn')
|
||||
export class OpsViewVpn extends DeesElement {
|
||||
@state()
|
||||
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
|
||||
this.vpnState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.vpnContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statusBadge.enabled {
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.statusBadge.disabled {
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.configDialog {
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
|
||||
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.configDialog pre {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('#1f2937', '#111827')};
|
||||
color: #10b981;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 8px 0;
|
||||
user-select: all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.configDialog .warning {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tagBadge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.serverInfo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')};
|
||||
}
|
||||
|
||||
.serverInfo .infoItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.serverInfo .infoLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
.serverInfo .infoValue {
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
const status = this.vpnState.status;
|
||||
const clients = this.vpnState.clients;
|
||||
const connectedCount = status?.connectedClients ?? 0;
|
||||
const totalClients = clients.length;
|
||||
const enabledClients = clients.filter(c => c.enabled).length;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalClients',
|
||||
title: 'Total Clients',
|
||||
type: 'number',
|
||||
value: totalClients,
|
||||
icon: 'lucide:users',
|
||||
description: 'Registered VPN clients',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'connectedClients',
|
||||
title: 'Connected',
|
||||
type: 'number',
|
||||
value: connectedCount,
|
||||
icon: 'lucide:link',
|
||||
description: 'Currently connected',
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'enabledClients',
|
||||
title: 'Enabled',
|
||||
type: 'number',
|
||||
value: enabledClients,
|
||||
icon: 'lucide:shieldCheck',
|
||||
description: 'Active client registrations',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'serverStatus',
|
||||
title: 'Server',
|
||||
type: 'text',
|
||||
value: status?.running ? 'Running' : 'Stopped',
|
||||
icon: 'lucide:server',
|
||||
description: status?.running ? `${status.forwardingMode} mode` : 'VPN server not running',
|
||||
color: status?.running ? '#10b981' : '#ef4444',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>VPN</ops-sectionheading>
|
||||
|
||||
${this.vpnState.newClientConfig ? html`
|
||||
<div class="configDialog">
|
||||
<strong>Client created successfully!</strong>
|
||||
<div class="warning">Copy the WireGuard config now. It contains private keys that won't be shown again.</div>
|
||||
<pre>${this.vpnState.newClientConfig}</pre>
|
||||
<dees-button
|
||||
@click=${async () => {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(this.vpnState.newClientConfig!);
|
||||
}
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 });
|
||||
}}
|
||||
>Copy to Clipboard</dees-button>
|
||||
<dees-button
|
||||
@click=${() => {
|
||||
const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'wireguard.conf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>Download .conf</dees-button>
|
||||
<dees-button
|
||||
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
|
||||
>Dismiss</dees-button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
|
||||
|
||||
${status ? html`
|
||||
<div class="serverInfo">
|
||||
<div class="infoItem">
|
||||
<span class="infoLabel">Subnet</span>
|
||||
<span class="infoValue">${status.subnet}</span>
|
||||
</div>
|
||||
<div class="infoItem">
|
||||
<span class="infoLabel">WireGuard Port</span>
|
||||
<span class="infoValue">${status.wgListenPort}</span>
|
||||
</div>
|
||||
<div class="infoItem">
|
||||
<span class="infoLabel">Forwarding Mode</span>
|
||||
<span class="infoValue">${status.forwardingMode}</span>
|
||||
</div>
|
||||
${status.serverPublicKeys ? html`
|
||||
<div class="infoItem">
|
||||
<span class="infoLabel">WG Public Key</span>
|
||||
<span class="infoValue" style="font-size: 11px; word-break: break-all;">${status.serverPublicKeys.wgPublicKey}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<dees-table
|
||||
.heading1=${'VPN Clients'}
|
||||
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
|
||||
.data=${clients}
|
||||
.displayFunction=${(client: interfaces.data.IVpnClient) => ({
|
||||
'Client ID': client.clientId,
|
||||
'Status': client.enabled
|
||||
? html`<span class="statusBadge enabled">enabled</span>`
|
||||
: html`<span class="statusBadge disabled">disabled</span>`,
|
||||
'VPN IP': client.assignedIp || '-',
|
||||
'Tags': client.tags?.length
|
||||
? html`${client.tags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||
: '-',
|
||||
'Description': client.description || '-',
|
||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Toggle',
|
||||
iconName: 'lucide:power',
|
||||
action: async (client: interfaces.data.IVpnClient) => {
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
||||
clientId: client.clientId,
|
||||
enabled: !client.enabled,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async (client: interfaces.data.IVpnClient) => {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Delete VPN Client',
|
||||
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
||||
{
|
||||
name: 'Delete',
|
||||
action: async (modal: any) => {
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
.createNewItem=${async () => {
|
||||
const { DeesModal, DeesForm, DeesInputText } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Create VPN Client',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text id="clientId" .label=${'Client ID'} .key=${'clientId'} required></dees-input-text>
|
||||
<dees-input-text id="description" .label=${'Description'} .key=${'description'}></dees-input-text>
|
||||
<dees-input-text id="tags" .label=${'Tags (comma-separated)'} .key=${'tags'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
||||
{
|
||||
name: 'Create',
|
||||
action: async (modal: any) => {
|
||||
const form = modal.shadowRoot!.querySelector('dees-form') as any;
|
||||
const data = await form.collectFormData();
|
||||
const 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ ts_web/
|
||||
|
||||
### State Management
|
||||
|
||||
The app uses `@push.rocks/smartstate` with multiple state parts:
|
||||
The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled actions with `autoPause: 'visibility'`, and batched updates:
|
||||
|
||||
| State Part | Mode | Description |
|
||||
|-----------|------|-------------|
|
||||
@@ -125,6 +125,16 @@ The app uses `@push.rocks/smartstate` with multiple state parts:
|
||||
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
||||
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
||||
|
||||
### Tab Visibility Optimization
|
||||
|
||||
The dashboard automatically pauses all background activity when the browser tab is hidden and resumes when visible:
|
||||
|
||||
- **Auto-refresh polling** uses `createScheduledAction` with `autoPause: 'visibility'` — stops HTTP requests while the tab is sleeping
|
||||
- **In-flight guard** prevents concurrent refresh requests from piling up
|
||||
- **WebSocket connection** disconnects when hidden and reconnects when visible, preventing log entry accumulation
|
||||
- **Network traffic timer** pauses chart updates when the tab is backgrounded
|
||||
- **Log entry batching** — incoming WebSocket log pushes are buffered and flushed once per animation frame to avoid per-entry re-renders
|
||||
|
||||
### Actions
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -38,7 +38,7 @@ class AppRouter {
|
||||
}
|
||||
|
||||
private setupStateSync(): void {
|
||||
appstate.uiStatePart.state.subscribe((uiState) => {
|
||||
appstate.uiStatePart.select().subscribe((uiState) => {
|
||||
if (this.suppressStateUpdate) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
Reference in New Issue
Block a user