Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99b40fea3f | |||
| 6f72e4fdbc | |||
| fbe845cd8e | |||
| 31413d28be | |||
| cd286cede6 | |||
| 36a3060cce | |||
| d2b108317e | |||
| dcd75f5e47 | |||
| 3d443fa147 | |||
| 2efdd2f16b | |||
| ec0348a83c | |||
| 6c4adf70c7 | |||
| 29d6076355 | |||
| fa96a41e68 | |||
| 1ea38ed5d2 | |||
| 7209903d02 | |||
| 20eda1ab3e | |||
| 44f2a7f0a9 | |||
| 0195a21f30 | |||
| 4dca747386 | |||
| 7663f502fa | |||
| 104cd417d8 | |||
| 93254d5d3d | |||
| 9a3f121a9c | |||
| bef74eb1aa | |||
| 308d8e4851 | |||
| dc010dc3ae | |||
| 61d5d3b1ad |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
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"]
|
||||
|
||||
99
changelog.md
99
changelog.md
@@ -1,5 +1,104 @@
|
||||
# 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
|
||||
|
||||
- add explicit unknown error typing and safe message access in logging and handler code
|
||||
- mark deferred-initialized class properties with definite assignment assertions to satisfy stricter TypeScript checks
|
||||
- harden ops web state access and action return types with non-null assertions and explicit Promise state typing
|
||||
- update storage reads to allow missing values and align license file references with the lowercase license filename
|
||||
|
||||
## 2026-03-26 - 11.10.5 - fix(build)
|
||||
rename smart tooling config to .smartconfig.json and update package references
|
||||
|
||||
- Moves the shared tool configuration from npmextra.json to .smartconfig.json.
|
||||
- Updates package.json published files and documentation to reference the new config file.
|
||||
- Refreshes several development and runtime dependency versions alongside the config migration.
|
||||
|
||||
## 2026-03-24 - 11.10.4 - fix(monitoring)
|
||||
handle multiple protocol cache entries per backend in metrics output
|
||||
|
||||
- Group detected protocol cache entries by backend host and port so multiple domain-specific records are preserved.
|
||||
- Emit one backend metrics row per cached domain and avoid dropping unmatched protocol cache entries by tracking seen entries with a composite host:port:domain key.
|
||||
- Use cached protocol values when available while keeping backend-only rows for metrics without protocol cache data.
|
||||
|
||||
## 2026-03-23 - 11.10.3 - fix(deps)
|
||||
bump tstest, smartmetrics, and taskbuffer to latest patch releases
|
||||
|
||||
- update @git.zone/tstest from ^3.5.0 to ^3.5.1
|
||||
- update @push.rocks/smartmetrics from ^3.0.2 to ^3.0.3
|
||||
- update @push.rocks/taskbuffer from ^8.0.0 to ^8.0.2
|
||||
|
||||
## 2026-03-23 - 11.10.2 - fix(deps)
|
||||
bump @api.global/typedserver to ^8.4.6 and @push.rocks/smartproxy to ^26.2.1
|
||||
|
||||
- Updates @api.global/typedserver from ^8.4.2 to ^8.4.6
|
||||
- Updates @push.rocks/smartproxy from ^26.2.0 to ^26.2.1
|
||||
|
||||
## 2026-03-23 - 11.10.1 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^26.2.0
|
||||
|
||||
- Updates the @push.rocks/smartproxy dependency from ^26.1.0 to ^26.2.0 in package.json.
|
||||
|
||||
## 2026-03-23 - 11.10.0 - feat(monitoring)
|
||||
add backend protocol metrics to network stats and ops dashboard
|
||||
|
||||
|
||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
47
package.json
47
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "11.10.0",
|
||||
"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",
|
||||
@@ -22,48 +22,49 @@
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.3.0",
|
||||
"@git.zone/tsbundle": "^2.9.1",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.5.0",
|
||||
"@git.zone/tswatch": "^3.3.0",
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.2",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.48.5",
|
||||
"@design.estate/dees-element": "^2.2.3",
|
||||
"@design.estate/dees-catalog": "^3.49.0",
|
||||
"@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.0",
|
||||
"@push.rocks/smartacme": "^9.3.1",
|
||||
"@push.rocks/smartdata": "^7.1.3",
|
||||
"@push.rocks/smartdb": "^2.0.0",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@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.2",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@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.1.0",
|
||||
"@push.rocks/smartproxy": "^27.1.0",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.2.0",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/taskbuffer": "^8.0.0",
|
||||
"@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.1",
|
||||
"@tsclass/tsclass": "^9.4.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"lru-cache": "^11.2.7",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
@@ -112,7 +113,7 @@
|
||||
"dist_ts_apiclient/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
]
|
||||
}
|
||||
|
||||
1689
pnpm-lock.yaml
generated
1689
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -133,7 +133,7 @@ The project now uses tswatch for development:
|
||||
```bash
|
||||
pnpm run watch
|
||||
```
|
||||
Configuration in `npmextra.json`:
|
||||
Configuration in `.smartconfig.json`:
|
||||
```json
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
|
||||
64
readme.md
64
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
|
||||
|
||||
@@ -1449,7 +1471,7 @@ The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsd
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.10.0',
|
||||
version: '11.13.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
20
ts/cache/classes.cache.cleaner.ts
vendored
20
ts/cache/classes.cache.cleaner.ts
vendored
@@ -48,14 +48,14 @@ export class CacheCleaner {
|
||||
this.isRunning = true;
|
||||
|
||||
// Run cleanup immediately on start
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||
this.runCleanup().catch((error: unknown) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
|
||||
});
|
||||
|
||||
// Schedule periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||
this.runCleanup().catch((error: unknown) => {
|
||||
logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
|
||||
@@ -113,8 +113,8 @@ export class CacheCleaner {
|
||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -138,14 +138,14 @@ export class CacheCleaner {
|
||||
try {
|
||||
await doc.delete();
|
||||
deletedCount++;
|
||||
} catch (deleteError) {
|
||||
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
||||
} catch (deleteError: unknown) {
|
||||
logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
2
ts/cache/classes.cached.document.ts
vendored
2
ts/cache/classes.cached.document.ts
vendored
@@ -22,7 +22,7 @@ export abstract class CachedDocument<T extends CachedDocument<T>> extends plugin
|
||||
* Timestamp when the document expires and should be cleaned up
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public expiresAt: Date;
|
||||
public expiresAt!: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last access (for LRU-style eviction if needed)
|
||||
|
||||
36
ts/cache/classes.cachedb.ts
vendored
36
ts/cache/classes.cachedb.ts
vendored
@@ -15,16 +15,16 @@ 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 smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
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
|
||||
@@ -89,8 +89,8 @@ export class CacheDb {
|
||||
|
||||
this.isStarted = true;
|
||||
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to start CacheDb: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -109,15 +109,15 @@ 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;
|
||||
logger.log('info', 'CacheDb stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error stopping CacheDb: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
28
ts/cache/documents/classes.cached.email.ts
vendored
28
ts/cache/documents/classes.cached.email.ts
vendored
@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
public id!: string;
|
||||
|
||||
/**
|
||||
* Email message ID (RFC 822 Message-ID header)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public messageId: string;
|
||||
public messageId!: string;
|
||||
|
||||
/**
|
||||
* Sender email address (envelope from)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public from: string;
|
||||
public from!: string;
|
||||
|
||||
/**
|
||||
* Recipient email addresses
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public to: string[];
|
||||
public to!: string[];
|
||||
|
||||
/**
|
||||
* CC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public cc: string[];
|
||||
public cc!: string[];
|
||||
|
||||
/**
|
||||
* BCC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bcc: string[];
|
||||
public bcc!: string[];
|
||||
|
||||
/**
|
||||
* Email subject
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public subject: string;
|
||||
public subject!: string;
|
||||
|
||||
/**
|
||||
* Raw RFC822 email content
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rawContent: string;
|
||||
public rawContent!: string;
|
||||
|
||||
/**
|
||||
* Current status of the email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TCachedEmailStatus;
|
||||
public status!: TCachedEmailStatus;
|
||||
|
||||
/**
|
||||
* Number of delivery attempts
|
||||
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
* Timestamp for next delivery attempt
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public nextAttempt: Date;
|
||||
public nextAttempt!: Date;
|
||||
|
||||
/**
|
||||
* Last error message if delivery failed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError: string;
|
||||
public lastError!: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the email was successfully delivered
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public deliveredAt: Date;
|
||||
public deliveredAt!: Date;
|
||||
|
||||
/**
|
||||
* Sender domain (for querying/filtering)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public senderDomain: string;
|
||||
public senderDomain!: string;
|
||||
|
||||
/**
|
||||
* Priority level (higher = more important)
|
||||
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
* JSON-serialized route data
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public routeData: string;
|
||||
public routeData!: string;
|
||||
|
||||
/**
|
||||
* DKIM signature status
|
||||
|
||||
@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress: string;
|
||||
public ipAddress!: string;
|
||||
|
||||
/**
|
||||
* Reputation score (0-100, higher = better)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public score: number;
|
||||
public score!: number;
|
||||
|
||||
/**
|
||||
* Whether the IP is flagged as spam source
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isSpam: boolean;
|
||||
public isSpam!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a known proxy
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isProxy: boolean;
|
||||
public isProxy!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a Tor exit node
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isTor: boolean;
|
||||
public isTor!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a VPN endpoint
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isVPN: boolean;
|
||||
public isVPN!: boolean;
|
||||
|
||||
/**
|
||||
* Country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string;
|
||||
public country!: string;
|
||||
|
||||
/**
|
||||
* Autonomous System Number
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: string;
|
||||
public asn!: string;
|
||||
|
||||
/**
|
||||
* Organization name
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public org: string;
|
||||
public org!: string;
|
||||
|
||||
/**
|
||||
* List of blacklists the IP appears on
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blacklists: string[];
|
||||
public blacklists!: string[];
|
||||
|
||||
/**
|
||||
* Number of times this IP has been checked
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,7 +236,7 @@ export class DcRouter {
|
||||
public emailServer?: UnifiedEmailServer;
|
||||
public radiusServer?: RadiusServer;
|
||||
public storageManager: StorageManager;
|
||||
public opsServer: OpsServer;
|
||||
public opsServer!: OpsServer;
|
||||
public metricsManager?: MetricsManager;
|
||||
|
||||
// Cache system (smartdata + LocalTsmDb)
|
||||
@@ -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();
|
||||
@@ -448,7 +490,7 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// DNS Server: optional, depends on SmartProxy
|
||||
if (this.options.dnsNsDomains?.length > 0 && this.options.dnsScopes?.length > 0) {
|
||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 && this.options.dnsScopes && this.options.dnsScopes.length > 0) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('DnsServer')
|
||||
.optional()
|
||||
@@ -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: {
|
||||
@@ -787,17 +907,25 @@ export class DcRouter {
|
||||
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
|
||||
eventComms.setSource('smartacme-dns-01');
|
||||
const isWildcardDomain = domain.startsWith('*.');
|
||||
const cert = await this.smartAcme.getCertificateForDomain(domain, {
|
||||
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,
|
||||
@@ -806,10 +934,10 @@ export class DcRouter {
|
||||
// Success — clear any backoff
|
||||
await scheduler.clearBackoff(domain);
|
||||
return result;
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
// Record failure for backoff tracking
|
||||
await scheduler.recordFailure(domain, err.message);
|
||||
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`);
|
||||
await scheduler.recordFailure(domain, (err as Error).message);
|
||||
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`);
|
||||
return 'http01';
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
*/
|
||||
@@ -1248,21 +1375,21 @@ export class DcRouter {
|
||||
// Wire delivery events to MetricsManager and logger
|
||||
if (this.metricsManager && this.emailServer.deliverySystem) {
|
||||
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
|
||||
this.metricsManager.trackEmailReceived(item?.from);
|
||||
this.metricsManager!.trackEmailReceived(item?.from);
|
||||
logger.log('info', `Email delivery started: ${item?.from} → ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
|
||||
this.metricsManager.trackEmailSent(item?.to);
|
||||
this.metricsManager!.trackEmailSent(item?.to);
|
||||
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
|
||||
this.metricsManager.trackEmailFailed(item?.to, error?.message);
|
||||
this.metricsManager!.trackEmailFailed(item?.to, error?.message);
|
||||
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
|
||||
});
|
||||
}
|
||||
if (this.metricsManager && this.emailServer) {
|
||||
this.emailServer.on('bounceProcessed', () => {
|
||||
this.metricsManager.trackEmailBounced();
|
||||
this.metricsManager!.trackEmailBounced();
|
||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||
});
|
||||
}
|
||||
@@ -1305,12 +1432,12 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
logger.log('info', 'All unified email components stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping unified email components: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error stopping unified email components: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update domain rules for email routing
|
||||
* @param rules New domain rules to apply
|
||||
@@ -1468,7 +1595,7 @@ export class DcRouter {
|
||||
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
|
||||
// Metrics tracking
|
||||
for (const question of event.questions) {
|
||||
this.metricsManager.trackDnsQuery(
|
||||
this.metricsManager?.trackDnsQuery(
|
||||
question.type,
|
||||
question.name,
|
||||
false,
|
||||
@@ -1553,8 +1680,8 @@ export class DcRouter {
|
||||
// Use the built-in socket handler from smartdns
|
||||
// This handles HTTP/2, DoH protocol, etc.
|
||||
await (this.dnsServer as any).handleHttpsSocket(socket);
|
||||
} catch (error) {
|
||||
logger.log('error', `DNS socket handler error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `DNS socket handler error: ${(error as Error).message}`);
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
@@ -1695,14 +1822,14 @@ export class DcRouter {
|
||||
} else {
|
||||
logger.log('warn', `Invalid DKIM record structure in ${file}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load DKIM record from ${file}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load DKIM records: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
@@ -1734,11 +1861,11 @@ export class DcRouter {
|
||||
// This ensures keys are ready even if DNS mode changes later
|
||||
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
|
||||
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.log('info', 'DKIM initialization complete');
|
||||
}
|
||||
|
||||
@@ -1779,10 +1906,10 @@ export class DcRouter {
|
||||
} else {
|
||||
logger.log('warn', 'Could not auto-discover public IPv4 address');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to auto-discover public IP: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to auto-discover public IP: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
|
||||
if (!publicIp) {
|
||||
logger.log('warn', 'No public IP available. Nameserver A records require either proxyIps, publicIp, or successful auto-discovery.');
|
||||
}
|
||||
@@ -1876,8 +2003,8 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to detect public IP: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('warn', `Failed to detect public IP: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1911,8 +2038,8 @@ export class DcRouter {
|
||||
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
|
||||
tlsConfig = { certPem, keyPem };
|
||||
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
|
||||
} catch (err) {
|
||||
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${err.message}`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export class ConfigValidator {
|
||||
} else if (rules.items.schema && itemType === 'object') {
|
||||
const itemResult = this.validate(value[i], rules.items.schema);
|
||||
if (!itemResult.valid) {
|
||||
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
|
||||
errors.push(...itemResult.errors!.map(err => `${key}[${i}].${err}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export class ConfigValidator {
|
||||
if (rules.schema) {
|
||||
const nestedResult = this.validate(value, rules.schema);
|
||||
if (!nestedResult.valid) {
|
||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
||||
errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
|
||||
}
|
||||
validatedConfig[key] = nestedResult.config;
|
||||
}
|
||||
@@ -233,8 +233,8 @@ export class ConfigValidator {
|
||||
|
||||
// Apply defaults to array items
|
||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||
result[key] = result[key].map(item =>
|
||||
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
||||
result[key] = result[key].map(item =>
|
||||
typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export class ConfigValidator {
|
||||
|
||||
if (!result.valid) {
|
||||
throw new ValidationError(
|
||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
||||
`Configuration validation failed: ${result.errors!.join(', ')}`,
|
||||
'CONFIG_VALIDATION_ERROR',
|
||||
{ data: { errors: result.errors } }
|
||||
);
|
||||
|
||||
@@ -227,7 +227,7 @@ export class PlatformError extends Error {
|
||||
const { retry } = this.context;
|
||||
if (!retry) return false;
|
||||
|
||||
return retry.currentRetry < retry.maxRetries;
|
||||
return (retry.currentRetry ?? 0) < (retry.maxRetries ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -296,11 +296,11 @@ export class MetricsManager {
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
|
||||
if (!proxyMetrics) {
|
||||
return [];
|
||||
return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
|
||||
}
|
||||
|
||||
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const connectionInfo = [];
|
||||
const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
|
||||
|
||||
for (const [routeName, count] of connectionsByRoute) {
|
||||
connectionInfo.push({
|
||||
@@ -595,47 +595,84 @@ export class MetricsManager {
|
||||
const backendMetrics = proxyMetrics.backends.byBackend();
|
||||
const protocolCache = proxyMetrics.backends.detectedProtocols();
|
||||
|
||||
// Index protocol cache by "host:port"
|
||||
const cacheByKey = new Map<string, (typeof protocolCache)[number]>();
|
||||
// Group protocol cache entries by host:port so we can match them to backend metrics.
|
||||
// The protocol cache is keyed by (host, port, domain) in Rust, so the same host:port
|
||||
// can have multiple entries for different domains.
|
||||
const cacheByBackend = new Map<string, (typeof protocolCache)[number][]>();
|
||||
for (const entry of protocolCache) {
|
||||
cacheByKey.set(`${entry.host}:${entry.port}`, entry);
|
||||
const backendKey = `${entry.host}:${entry.port}`;
|
||||
let entries = cacheByBackend.get(backendKey);
|
||||
if (!entries) {
|
||||
entries = [];
|
||||
cacheByBackend.set(backendKey, entries);
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
const backends: Array<any> = [];
|
||||
const seen = new Set<string>();
|
||||
const seenCacheKeys = new Set<string>();
|
||||
|
||||
for (const [key, bm] of backendMetrics) {
|
||||
seen.add(key);
|
||||
const cache = cacheByKey.get(key);
|
||||
backends.push({
|
||||
backend: key,
|
||||
domain: cache?.domain ?? null,
|
||||
protocol: bm.protocol,
|
||||
activeConnections: bm.activeConnections,
|
||||
totalConnections: bm.totalConnections,
|
||||
connectErrors: bm.connectErrors,
|
||||
handshakeErrors: bm.handshakeErrors,
|
||||
requestErrors: bm.requestErrors,
|
||||
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||
h2Failures: bm.h2Failures,
|
||||
h2Suppressed: cache?.h2Suppressed ?? false,
|
||||
h3Suppressed: cache?.h3Suppressed ?? false,
|
||||
h2CooldownRemainingSecs: cache?.h2CooldownRemainingSecs ?? null,
|
||||
h3CooldownRemainingSecs: cache?.h3CooldownRemainingSecs ?? null,
|
||||
h2ConsecutiveFailures: cache?.h2ConsecutiveFailures ?? null,
|
||||
h3ConsecutiveFailures: cache?.h3ConsecutiveFailures ?? null,
|
||||
h3Port: cache?.h3Port ?? null,
|
||||
cacheAgeSecs: cache?.ageSecs ?? null,
|
||||
});
|
||||
const cacheEntries = cacheByBackend.get(key);
|
||||
if (!cacheEntries || cacheEntries.length === 0) {
|
||||
// No protocol cache entry — emit one row with backend metrics only
|
||||
backends.push({
|
||||
backend: key,
|
||||
domain: null,
|
||||
protocol: bm.protocol,
|
||||
activeConnections: bm.activeConnections,
|
||||
totalConnections: bm.totalConnections,
|
||||
connectErrors: bm.connectErrors,
|
||||
handshakeErrors: bm.handshakeErrors,
|
||||
requestErrors: bm.requestErrors,
|
||||
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||
h2Failures: bm.h2Failures,
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
cacheAgeSecs: null,
|
||||
});
|
||||
} else {
|
||||
// One row per domain, each enriched with the shared backend metrics
|
||||
for (const cache of cacheEntries) {
|
||||
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
|
||||
seenCacheKeys.add(compositeKey);
|
||||
backends.push({
|
||||
backend: key,
|
||||
domain: cache.domain ?? null,
|
||||
protocol: cache.protocol ?? bm.protocol,
|
||||
activeConnections: bm.activeConnections,
|
||||
totalConnections: bm.totalConnections,
|
||||
connectErrors: bm.connectErrors,
|
||||
handshakeErrors: bm.handshakeErrors,
|
||||
requestErrors: bm.requestErrors,
|
||||
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||
h2Failures: bm.h2Failures,
|
||||
h2Suppressed: cache.h2Suppressed,
|
||||
h3Suppressed: cache.h3Suppressed,
|
||||
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
|
||||
h3CooldownRemainingSecs: cache.h3CooldownRemainingSecs,
|
||||
h2ConsecutiveFailures: cache.h2ConsecutiveFailures,
|
||||
h3ConsecutiveFailures: cache.h3ConsecutiveFailures,
|
||||
h3Port: cache.h3Port,
|
||||
cacheAgeSecs: cache.ageSecs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include protocol cache entries with no matching backend metric
|
||||
for (const entry of protocolCache) {
|
||||
const key = `${entry.host}:${entry.port}`;
|
||||
if (!seen.has(key)) {
|
||||
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
|
||||
if (!seenCacheKeys.has(compositeKey)) {
|
||||
backends.push({
|
||||
backend: key,
|
||||
backend: `${entry.host}:${entry.port}`,
|
||||
domain: entry.domain,
|
||||
protocol: entry.protocol,
|
||||
activeConnections: 0,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js'
|
||||
|
||||
export class OpsServer {
|
||||
public dcRouterRef: DcRouter;
|
||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||
public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||
|
||||
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -17,17 +17,18 @@ export class OpsServer {
|
||||
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||
|
||||
// Handler instances
|
||||
public adminHandler: handlers.AdminHandler;
|
||||
private configHandler: handlers.ConfigHandler;
|
||||
private logsHandler: handlers.LogsHandler;
|
||||
private securityHandler: handlers.SecurityHandler;
|
||||
private statsHandler: handlers.StatsHandler;
|
||||
private radiusHandler: handlers.RadiusHandler;
|
||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||
private certificateHandler: handlers.CertificateHandler;
|
||||
private remoteIngressHandler: handlers.RemoteIngressHandler;
|
||||
private routeManagementHandler: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler: handlers.ApiTokenHandler;
|
||||
public adminHandler!: handlers.AdminHandler;
|
||||
private configHandler!: handlers.ConfigHandler;
|
||||
private logsHandler!: handlers.LogsHandler;
|
||||
private securityHandler!: handlers.SecurityHandler;
|
||||
private statsHandler!: handlers.StatsHandler;
|
||||
private radiusHandler!: handlers.RadiusHandler;
|
||||
private emailOpsHandler!: handlers.EmailOpsHandler;
|
||||
private certificateHandler!: handlers.CertificateHandler;
|
||||
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||
private vpnHandler!: handlers.VpnHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -39,7 +40,7 @@ export class OpsServer {
|
||||
public async start() {
|
||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||
domain: 'localhost',
|
||||
feedMetadata: null,
|
||||
feedMetadata: undefined,
|
||||
serveDir: paths.distServe,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class AdminHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// JWT instance
|
||||
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
|
||||
// Simple in-memory user storage (in production, use proper database)
|
||||
private users = new Map<string, {
|
||||
|
||||
@@ -311,8 +311,8 @@ export class CertificateHandler {
|
||||
}
|
||||
}
|
||||
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,8 +340,8 @@ export class CertificateHandler {
|
||||
try {
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,8 +351,8 @@ export class CertificateHandler {
|
||||
try {
|
||||
await smartProxy.provisionCertificate(routeNames[0]);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -52,8 +52,8 @@ export class RadiusHandler {
|
||||
try {
|
||||
await radiusServer.addClient(dataArg.client);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, message: (error as Error).message };
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -144,8 +144,8 @@ export class RadiusHandler {
|
||||
updatedAt: mapping.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, message: (error as Error).message };
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -279,7 +279,7 @@ export class StatsHandler {
|
||||
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
const stats = await this.opsServerRef.dcRouterRef.metricsManager!.getNetworkStats();
|
||||
const serverStats = await this.collectServerStats();
|
||||
|
||||
// Build per-IP bandwidth lookup from throughputByIP
|
||||
|
||||
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)
|
||||
|
||||
@@ -518,8 +518,8 @@ export class AccountingManager {
|
||||
if (deletedCount > 0) {
|
||||
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to cleanup old sessions: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to cleanup old sessions: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
@@ -582,8 +582,8 @@ export class AccountingManager {
|
||||
// Ignore individual errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load active sessions: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,8 +598,8 @@ export class AccountingManager {
|
||||
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
||||
try {
|
||||
await this.storageManager.setJSON(key, session);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to persist session ${session.sessionId}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,8 +620,8 @@ export class AccountingManager {
|
||||
const date = new Date(session.endTime);
|
||||
const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`;
|
||||
await this.storageManager.setJSON(archiveKey, session);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to archive session ${session.sessionId}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,8 +653,8 @@ export class AccountingManager {
|
||||
// Ignore individual errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to get archived sessions: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return sessions;
|
||||
|
||||
@@ -310,8 +310,8 @@ export class RadiusServer {
|
||||
default:
|
||||
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `RADIUS accounting error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||
|
||||
@@ -104,7 +104,7 @@ export class VlanManager {
|
||||
if (this.normalizedMacCache.size > 10000) {
|
||||
const iterator = this.normalizedMacCache.keys();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
this.normalizedMacCache.delete(iterator.next().value);
|
||||
this.normalizedMacCache.delete(iterator.next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,8 +348,8 @@ export class VlanManager {
|
||||
}
|
||||
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +364,8 @@ export class VlanManager {
|
||||
try {
|
||||
const mappings = Array.from(this.mappings.values());
|
||||
await this.storageManager.setJSON(this.config.storagePrefix, mappings);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to save VLAN mappings to storage: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks c
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export enum ThreatCategory {
|
||||
* Content Scanner for detecting malicious email content
|
||||
*/
|
||||
export class ContentScanner {
|
||||
private static instance: ContentScanner;
|
||||
private static instance: ContentScanner | undefined;
|
||||
private scanCache: LRUCache<string, IScanResult>;
|
||||
private options: Required<IContentScannerOptions>;
|
||||
|
||||
@@ -258,12 +258,12 @@ export class ContentScanner {
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error scanning email: ${error.message}`, {
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error scanning email: ${(error as Error).message}`, {
|
||||
messageId: email.getMessageId(),
|
||||
error: error.stack
|
||||
error: (error as Error).stack
|
||||
});
|
||||
|
||||
|
||||
// Return a safe default with error indication
|
||||
return {
|
||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
||||
@@ -271,7 +271,7 @@ export class ContentScanner {
|
||||
scannedElements: ['error'],
|
||||
timestamp: Date.now(),
|
||||
threatType: 'scan_error',
|
||||
threatDetails: `Scan error: ${error.message}`
|
||||
threatDetails: `Scan error: ${(error as Error).message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -625,8 +625,8 @@ export class ContentScanner {
|
||||
return sample.toString('utf8')
|
||||
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
||||
.replace(/\uFFFD/g, ''); // Remove replacement char
|
||||
} catch (error) {
|
||||
logger.log('warn', `Error extracting text from buffer: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('warn', `Error extracting text from buffer: ${(error as Error).message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -699,10 +699,10 @@ export class ContentScanner {
|
||||
subject: email.subject
|
||||
},
|
||||
success: false,
|
||||
domain: email.getFromDomain()
|
||||
domain: email.getFromDomain() ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Log a threat finding to the security logger
|
||||
* @param email The email containing the threat
|
||||
@@ -722,10 +722,10 @@ export class ContentScanner {
|
||||
subject: email.subject
|
||||
},
|
||||
success: false,
|
||||
domain: email.getFromDomain()
|
||||
domain: email.getFromDomain() ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get threat level description based on score
|
||||
* @param score Threat score
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface IIPReputationOptions {
|
||||
* Class for checking IP reputation of inbound email senders
|
||||
*/
|
||||
export class IPReputationChecker {
|
||||
private static instance: IPReputationChecker;
|
||||
private static instance: IPReputationChecker | undefined;
|
||||
private reputationCache: LRUCache<string, IReputationResult>;
|
||||
private options: Required<IIPReputationOptions>;
|
||||
private storageManager?: any; // StorageManager instance
|
||||
@@ -127,8 +127,8 @@ export class IPReputationChecker {
|
||||
// Load cache from disk if enabled
|
||||
if (this.options.enableLocalCache) {
|
||||
// Fire and forget the load operation
|
||||
this.loadCache().catch(error => {
|
||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
|
||||
this.loadCache().catch((error: unknown) => {
|
||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${(error as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -237,13 +237,13 @@ export class IPReputationChecker {
|
||||
this.logReputationCheck(ip, result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
|
||||
ip,
|
||||
stack: error.stack
|
||||
stack: (error as Error).stack
|
||||
});
|
||||
|
||||
return this.createErrorResult(ip, error.message);
|
||||
|
||||
return this.createErrorResult(ip, (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,8 +266,8 @@ export class IPReputationChecker {
|
||||
const lookupDomain = `${reversedIP}.${server}`;
|
||||
await plugins.dns.promises.resolve(lookupDomain);
|
||||
return server; // IP is listed in this DNSBL
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
} catch (error: unknown) {
|
||||
if ((error as any).code === 'ENOTFOUND') {
|
||||
return null; // IP is not listed in this DNSBL
|
||||
}
|
||||
throw error; // Other error
|
||||
@@ -286,8 +286,8 @@ export class IPReputationChecker {
|
||||
listCount: lists.length,
|
||||
lists
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error checking DNSBL for ${ip}: ${(error as Error).message}`);
|
||||
return {
|
||||
listCount: 0,
|
||||
lists: []
|
||||
@@ -349,8 +349,8 @@ export class IPReputationChecker {
|
||||
org: this.determineOrg(ip), // Simplified, would use real org data
|
||||
type
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error getting IP info for ${ip}: ${(error as Error).message}`);
|
||||
return {
|
||||
type: IPType.UNKNOWN
|
||||
};
|
||||
@@ -468,8 +468,8 @@ export class IPReputationChecker {
|
||||
}
|
||||
this.saveCacheTimer = setTimeout(() => {
|
||||
this.saveCacheTimer = null;
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
this.saveCache().catch((error: unknown) => {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${(error as Error).message}`);
|
||||
});
|
||||
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
|
||||
}
|
||||
@@ -506,11 +506,11 @@ export class IPReputationChecker {
|
||||
|
||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load cache from disk or storage manager
|
||||
*/
|
||||
@@ -542,12 +542,12 @@ export class IPReputationChecker {
|
||||
plugins.fs.unlinkSync(cacheFile);
|
||||
logger.log('info', 'Old cache file removed after migration');
|
||||
} catch (deleteError) {
|
||||
logger.log('warn', `Could not delete old cache file: ${deleteError.message}`);
|
||||
logger.log('warn', `Could not delete old cache file: ${(deleteError as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Error loading from StorageManager: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error loading from StorageManager: ${(error as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
// No storage manager, load from filesystem
|
||||
@@ -578,8 +578,8 @@ export class IPReputationChecker {
|
||||
const source = fromFilesystem ? 'disk' : 'StorageManager';
|
||||
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load IP reputation cache: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load IP reputation cache: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,8 +611,8 @@ export class IPReputationChecker {
|
||||
|
||||
// If cache is enabled and we have entries, save them to the new storage manager
|
||||
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
|
||||
this.saveCache().catch((error: unknown) => {
|
||||
logger.log('error', `Failed to save cache to new storage manager: ${(error as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface ISecurityEvent {
|
||||
* Security logger for enhanced security monitoring
|
||||
*/
|
||||
export class SecurityLogger {
|
||||
private static instance: SecurityLogger;
|
||||
private static instance: SecurityLogger | undefined;
|
||||
private securityEvents: ISecurityEvent[] = [];
|
||||
private maxEventHistory: number;
|
||||
private enableNotifications: boolean;
|
||||
@@ -154,11 +154,13 @@ export class SecurityLogger {
|
||||
}
|
||||
|
||||
if (filter.fromTimestamp) {
|
||||
filteredEvents = filteredEvents.filter(event => event.timestamp >= filter.fromTimestamp);
|
||||
const fromTs = filter.fromTimestamp;
|
||||
filteredEvents = filteredEvents.filter(event => event.timestamp >= fromTs);
|
||||
}
|
||||
|
||||
|
||||
if (filter.toTimestamp) {
|
||||
filteredEvents = filteredEvents.filter(event => event.timestamp <= filter.toTimestamp);
|
||||
const toTs = filter.toTimestamp;
|
||||
filteredEvents = filteredEvents.filter(event => event.timestamp <= toTs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { smsConfigSchema } from './config/sms.schema.js';
|
||||
import { ConfigValidator } from '../config/validator.js';
|
||||
|
||||
export class SmsService {
|
||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||
public projectinfo!: plugins.projectinfo.ProjectInfo;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
public config: ISmsConfig;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class SmsService {
|
||||
const validationResult = ConfigValidator.validate(options, smsConfigSchema);
|
||||
|
||||
if (!validationResult.valid) {
|
||||
logger.warn(`SMS service configuration has validation errors: ${validationResult.errors.join(', ')}`);
|
||||
logger.warn(`SMS service configuration has validation errors: ${validationResult.errors!.join(', ')}`);
|
||||
}
|
||||
|
||||
// Set configuration with defaults
|
||||
@@ -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',
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface IStorageConfig {
|
||||
/** Filesystem path for storage */
|
||||
fsPath?: string;
|
||||
/** Custom read function */
|
||||
readFunction?: (key: string) => Promise<string>;
|
||||
readFunction?: (key: string) => Promise<string | null>;
|
||||
/** Custom write function */
|
||||
writeFunction?: (key: string, value: string) => Promise<void>;
|
||||
}
|
||||
@@ -57,9 +57,7 @@ export class StorageManager {
|
||||
this.ensureDirectory(this.fsBasePath);
|
||||
|
||||
// Set up internal filesystem read/write functions
|
||||
this.config.readFunction = async (key: string) => {
|
||||
return this.fsRead(key);
|
||||
};
|
||||
this.config.readFunction = (key: string): Promise<string | null> => this.fsRead(key);
|
||||
this.config.writeFunction = async (key: string, value: string) => {
|
||||
await this.fsWrite(key, value);
|
||||
};
|
||||
@@ -88,8 +86,8 @@ export class StorageManager {
|
||||
private async ensureDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await plugins.fsUtils.ensureDir(dirPath);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to create storage directory: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to create storage directory: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -129,19 +127,19 @@ export class StorageManager {
|
||||
/**
|
||||
* Internal filesystem read function
|
||||
*/
|
||||
private async fsRead(key: string): Promise<string> {
|
||||
private async fsRead(key: string): Promise<string | null> {
|
||||
const filePath = this.keyToPath(key);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
return content;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
} catch (error: unknown) {
|
||||
if ((error as any).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Internal filesystem write function
|
||||
*/
|
||||
@@ -186,8 +184,8 @@ export class StorageManager {
|
||||
default:
|
||||
throw new Error(`Unknown backend: ${this.backend}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Storage get error for key ${key}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Storage get error for key ${key}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -230,7 +228,7 @@ export class StorageManager {
|
||||
this.memoryStore.set(key, value);
|
||||
// Evict oldest entries if memory store exceeds limit
|
||||
while (this.memoryStore.size > StorageManager.MAX_MEMORY_ENTRIES) {
|
||||
const firstKey = this.memoryStore.keys().next().value;
|
||||
const firstKey = this.memoryStore.keys().next().value!;
|
||||
this.memoryStore.delete(firstKey);
|
||||
}
|
||||
break;
|
||||
@@ -239,8 +237,8 @@ export class StorageManager {
|
||||
default:
|
||||
throw new Error(`Unknown backend: ${this.backend}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Storage set error for key ${key}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Storage set error for key ${key}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -257,8 +255,8 @@ export class StorageManager {
|
||||
const filePath = this.keyToPath(key);
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
} catch (error: unknown) {
|
||||
if ((error as any).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -281,8 +279,8 @@ export class StorageManager {
|
||||
default:
|
||||
throw new Error(`Unknown backend: ${this.backend}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Storage delete error for key ${key}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Storage delete error for key ${key}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -319,8 +317,8 @@ export class StorageManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
} catch (error: unknown) {
|
||||
if ((error as any).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -348,8 +346,8 @@ export class StorageManager {
|
||||
default:
|
||||
throw new Error(`Unknown backend: ${this.backend}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Storage list error for prefix ${prefix}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Storage list error for prefix ${prefix}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -390,8 +388,8 @@ export class StorageManager {
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to parse JSON for key ${key}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to parse JSON for key ${key}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -259,7 +259,7 @@ Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`)
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -1,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;
|
||||
}
|
||||
@@ -280,7 +280,7 @@ console.log('Connection token:', tokenResponse.token);
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -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.0',
|
||||
version: '11.13.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -195,17 +201,18 @@ export class OpsDashboard extends DeesElement {
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login');
|
||||
simpleLogin.addEventListener('login', (e: CustomEvent) => {
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
simpleLogin.addEventListener('login', (e: Event) => {
|
||||
// Handle logout event
|
||||
this.login(e.detail.data.username, e.detail.data.password);
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.login(detail.data.username, detail.data.password);
|
||||
});
|
||||
|
||||
// Handle view changes
|
||||
const appDash = this.shadowRoot.querySelector('dees-simple-appdash');
|
||||
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
|
||||
if (appDash) {
|
||||
appDash.addEventListener('view-select', (e: CustomEvent) => {
|
||||
const viewName = e.detail.view.name.toLowerCase();
|
||||
appDash.addEventListener('view-select', (e: Event) => {
|
||||
const viewName = (e as CustomEvent).detail.view.name.toLowerCase();
|
||||
// Use router for navigation instead of direct state update
|
||||
appRouter.navigateToView(viewName);
|
||||
});
|
||||
@@ -217,7 +224,7 @@ export class OpsDashboard extends DeesElement {
|
||||
}
|
||||
|
||||
// Handle initial state - check if we have a stored session that's still valid
|
||||
const loginState = appstate.loginStatePart.getState();
|
||||
const loginState = appstate.loginStatePart.getState()!;
|
||||
if (loginState.identity?.jwt) {
|
||||
if (loginState.identity.expiresAt > Date.now()) {
|
||||
// Client-side expiry looks valid — verify with server (keypair may have changed)
|
||||
@@ -229,7 +236,7 @@ export class OpsDashboard extends DeesElement {
|
||||
if (response.valid) {
|
||||
// JWT confirmed valid by server
|
||||
this.loginState = loginState;
|
||||
await simpleLogin.switchToSlottedContent();
|
||||
await (simpleLogin as any).switchToSlottedContent();
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
} else {
|
||||
@@ -250,8 +257,8 @@ export class OpsDashboard extends DeesElement {
|
||||
private async login(username: string, password: string) {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
console.log(`Attempting to login...`);
|
||||
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login');
|
||||
const form = simpleLogin.shadowRoot.querySelector('dees-form');
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
|
||||
form.setStatus('pending', 'Logging in...');
|
||||
|
||||
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
|
||||
@@ -262,14 +269,14 @@ export class OpsDashboard extends DeesElement {
|
||||
if (state.identity) {
|
||||
console.log('Login successful');
|
||||
this.loginState = state;
|
||||
form.setStatus('success', 'Logged in!');
|
||||
await simpleLogin.switchToSlottedContent();
|
||||
form!.setStatus('success', 'Logged in!');
|
||||
await simpleLogin!.switchToSlottedContent();
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
} else {
|
||||
form.setStatus('error', 'Login failed!');
|
||||
form!.setStatus('error', 'Login failed!');
|
||||
await domtools.convenience.smartdelay.delayFor(2000);
|
||||
form.reset();
|
||||
form!.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,11 @@ declare global {
|
||||
@customElement('ops-view-certificates')
|
||||
export class OpsViewCertificates extends DeesElement {
|
||||
@state()
|
||||
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState();
|
||||
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.certificateStatePart.state.subscribe((newState) => {
|
||||
const sub = appstate.certificateStatePart.select().subscribe((newState) => {
|
||||
this.certState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
@@ -264,10 +264,10 @@ export class OpsViewCertificates extends DeesElement {
|
||||
{
|
||||
name: 'Import',
|
||||
iconName: 'lucide:upload',
|
||||
action: async (modal) => {
|
||||
action: async (modal: any) => {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
try {
|
||||
const form = modal.shadowRoot.querySelector('dees-form') as any;
|
||||
const form = modal.shadowRoot!.querySelector('dees-form') as any;
|
||||
const formData = await form.collectFormData();
|
||||
const files = formData.certJsonFile;
|
||||
if (!files || files.length === 0) {
|
||||
@@ -287,8 +287,8 @@ export class OpsViewCertificates extends DeesElement {
|
||||
);
|
||||
DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 });
|
||||
modal.destroy();
|
||||
} catch (err) {
|
||||
DeesToast.show({ message: `Import failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||
} catch (err: unknown) {
|
||||
DeesToast.show({ message: `Import failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -339,8 +339,8 @@ export class OpsViewCertificates extends DeesElement {
|
||||
} else {
|
||||
DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 });
|
||||
}
|
||||
} catch (err) {
|
||||
DeesToast.show({ message: `Export failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||
} catch (err: unknown) {
|
||||
DeesToast.show({ message: `Export failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -363,7 +363,7 @@ export class OpsViewCertificates extends DeesElement {
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modal) => {
|
||||
action: async (modal: any) => {
|
||||
try {
|
||||
await appstate.certificateStatePart.dispatchAction(
|
||||
appstate.deleteCertificateAction,
|
||||
@@ -371,8 +371,8 @@ export class OpsViewCertificates extends DeesElement {
|
||||
);
|
||||
DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 });
|
||||
modal.destroy();
|
||||
} catch (err) {
|
||||
DeesToast.show({ message: `Delete failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||
} catch (err: unknown) {
|
||||
DeesToast.show({ message: `Delete failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -102,7 +102,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
|
||||
private renderSystemSection(sys: NonNullable<appstate.IConfigState['config']>['system']): TemplateResult {
|
||||
// Annotate proxy IPs with source hint when Remote Ingress is active
|
||||
const ri = this.configState.config?.remoteIngress;
|
||||
let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null;
|
||||
@@ -133,7 +133,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult {
|
||||
private renderSmartProxySection(proxy: NonNullable<appstate.IConfigState['config']>['smartProxy']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Route Count', value: proxy.routeCount },
|
||||
];
|
||||
@@ -164,7 +164,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult {
|
||||
private renderEmailSection(email: NonNullable<appstate.IConfigState['config']>['email']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' },
|
||||
{ key: 'Hostname', value: email.hostname },
|
||||
@@ -196,7 +196,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDnsSection(dns: appstate.IConfigState['config']['dns']): TemplateResult {
|
||||
private renderDnsSection(dns: NonNullable<appstate.IConfigState['config']>['dns']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Port', value: dns.port },
|
||||
{ key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' },
|
||||
@@ -216,7 +216,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult {
|
||||
private renderTlsSection(tls: NonNullable<appstate.IConfigState['config']>['tls']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Contact Email', value: tls.contactEmail },
|
||||
{ key: 'Domain', value: tls.domain },
|
||||
@@ -242,7 +242,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult {
|
||||
private renderCacheSection(cache: NonNullable<appstate.IConfigState['config']>['cache']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Storage Path', value: cache.storagePath },
|
||||
{ key: 'DB Name', value: cache.dbName },
|
||||
@@ -267,7 +267,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult {
|
||||
private renderRadiusSection(radius: NonNullable<appstate.IConfigState['config']>['radius']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Auth Port', value: radius.authPort },
|
||||
{ key: 'Accounting Port', value: radius.acctPort },
|
||||
@@ -296,7 +296,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult {
|
||||
private renderRemoteIngressSection(ri: NonNullable<appstate.IConfigState['config']>['remoteIngress']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Tunnel Port', value: ri.tunnelPort },
|
||||
{ key: 'Hub Domain', value: ri.hubDomain },
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -83,13 +83,13 @@ export class OpsViewEmails extends DeesElement {
|
||||
private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
|
||||
const emailSummary = e.detail;
|
||||
try {
|
||||
const context = appstate.loginStatePart.getState();
|
||||
const context = appstate.loginStatePart.getState()!;
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailDetail
|
||||
>('/typedrequest', 'getEmailDetail');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
identity: context.identity!,
|
||||
emailId: emailSummary.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,10 +29,10 @@ interface INetworkRequest {
|
||||
@customElement('ops-view-network')
|
||||
export class OpsViewNetwork extends DeesElement {
|
||||
@state()
|
||||
accessor statsState = appstate.statsStatePart.getState();
|
||||
accessor statsState = appstate.statsStatePart.getState()!;
|
||||
|
||||
@state()
|
||||
accessor networkState = appstate.networkStatePart.getState();
|
||||
accessor networkState = appstate.networkStatePart.getState()!;
|
||||
|
||||
|
||||
@state()
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -21,11 +21,11 @@ declare global {
|
||||
@customElement('ops-view-remoteingress')
|
||||
export class OpsViewRemoteIngress extends DeesElement {
|
||||
@state()
|
||||
accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState();
|
||||
accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => {
|
||||
const sub = appstate.remoteIngressStatePart.select().subscribe((newState) => {
|
||||
this.riState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
@@ -184,7 +184,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
@click=${async () => {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
try {
|
||||
const response = await appstate.fetchConnectionToken(this.riState.newEdgeId);
|
||||
const response = await appstate.fetchConnectionToken(this.riState.newEdgeId!);
|
||||
if (response.success && response.token) {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(response.token);
|
||||
@@ -202,8 +202,8 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
} else {
|
||||
DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 });
|
||||
}
|
||||
} catch (err) {
|
||||
DeesToast.show({ message: `Failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||
} catch (err: unknown) {
|
||||
DeesToast.show({ message: `Failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
|
||||
}
|
||||
}}
|
||||
>Copy Connection Token</dees-button>
|
||||
@@ -399,8 +399,8 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
} else {
|
||||
DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 });
|
||||
}
|
||||
} catch (err) {
|
||||
DeesToast.show({ message: `Failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||
} catch (err: unknown) {
|
||||
DeesToast.show({ message: `Failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
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
|
||||
@@ -237,7 +247,7 @@ export class OpsViewMyView extends DeesElement {
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -71,12 +71,12 @@ class AppRouter {
|
||||
|
||||
private updateViewState(view: string): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
const currentState = appstate.uiStatePart.getState()!;
|
||||
if (currentState.activeView !== view) {
|
||||
appstate.uiStatePart.setState({
|
||||
...currentState,
|
||||
activeView: view,
|
||||
});
|
||||
} as appstate.IUiState);
|
||||
}
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class AppRouter {
|
||||
}
|
||||
|
||||
public getCurrentView(): string {
|
||||
return appstate.uiStatePart.getState().activeView;
|
||||
return appstate.uiStatePart.getState()!.activeView;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
|
||||
Reference in New Issue
Block a user