Compare commits
281 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d46ec709b | |||
| 2b4bf42812 | |||
| 6faccc643b | |||
| 9f63908f7d | |||
| 5889eb5210 | |||
| 1de40c0d4e | |||
| e201efe0b4 | |||
| f8c582ee9b | |||
| bf9e85f518 | |||
| 0366ec8160 | |||
| 58bd4a0d33 | |||
| bbff76814e | |||
| 292486c33b | |||
| 2df8937d86 | |||
| 14aa1fa1d4 | |||
| 7a4214a7b8 | |||
| bd6130013c | |||
| 0f814bbcdd | |||
| 8ec94b7dae | |||
| d5dfe439c7 | |||
| aaf3c9cb1c | |||
| abde872ab2 | |||
| ca2d2b09ad | |||
| fb7d4d988b | |||
| 26e6eea5d5 | |||
| 2458dd08d8 | |||
| dee648b3bc | |||
| f4ed32cee4 | |||
| e9c72952ab | |||
| 1bd485c43e | |||
| 421a0390ba | |||
| c7f87a7c22 | |||
| 390d5c648f | |||
| ec651c1cdb | |||
| 6f82c393e7 | |||
| afdb48367b | |||
| 53526ca3ba | |||
| 07e8f4489b | |||
| 14101a09d3 | |||
| 5344d53806 | |||
| 971535926c | |||
| c13a4ae4be | |||
| e7a03c48ae | |||
| a682329a3f | |||
| c4580f9874 | |||
| b331065b8c | |||
| 4675ca3e89 | |||
| 70e2c8e17d | |||
| db53d87cc5 | |||
| ff6244d3d1 | |||
| f0aafe9027 | |||
| 487f2acac8 | |||
| 0a5e35c58e | |||
| 34c0cab5dc | |||
| 3a666e9300 | |||
| cbe1b5d37d | |||
| 30f2044d9f | |||
| 593b000ca3 | |||
| 60c298c396 | |||
| d7f1c16454 | |||
| 4290d4be86 | |||
| bc34cb5eab | |||
| eda12f3ce3 | |||
| 65f19aac72 | |||
| 29a992a695 | |||
| dbb2166a8f | |||
| 22691329a5 | |||
| e098e1a2ad | |||
| 16d64ec988 | |||
| cb1332ff76 | |||
| 3e52060788 | |||
| f041891a3f | |||
| f902c2c1db | |||
| e1a9e1f997 | |||
| d7b39a3017 | |||
| 0f41b0d8c7 | |||
| 2d33c037ba | |||
| dca7b37eb8 | |||
| b56598ba00 | |||
| bbf550b183 | |||
| f4fc5eb1fd | |||
| d9e88cf5f9 | |||
| eccb9706f2 | |||
| 285e681413 | |||
| 4f3958d94d | |||
| d19f22255d | |||
| 87ec55619a | |||
| b91dab0f85 | |||
| df573d498e | |||
| da2b838019 | |||
| 107adeee1d | |||
| 45f933b473 | |||
| ad16bc44f1 | |||
| 96d5b7e01a | |||
| 93ffcf86b3 | |||
| de98b070db | |||
| d3d2bde440 | |||
| 0840b2b571 | |||
| fa2e784eaa | |||
| 64f2854023 | |||
| 03e3261755 | |||
| c724e68b8c | |||
| f8f66d1392 | |||
| c66bdc9f88 | |||
| 8d57547ace | |||
| 54eaf23298 | |||
| 7148306381 | |||
| d3aefef78d | |||
| ecd0cc0066 | |||
| eac490297a | |||
| de65641f6f | |||
| ffddc1a5f5 | |||
| 26152e0520 | |||
| f79ad07a57 | |||
| 76d5b9bf7c | |||
| 670b67eecf | |||
| 174af5cf86 | |||
| a1f5e45e94 | |||
| d06165bd0c | |||
| 8f3c6fdf23 | |||
| 106ef2919e | |||
| 3d7fd233cf | |||
| 34d40f7370 | |||
| 89b9d01628 | |||
| ed3964e892 | |||
| baab152fd3 | |||
| 9baf09ff61 | |||
| 71f23302d3 | |||
| ecbaab3000 | |||
| 8cb1f3c12d | |||
| c7d7f92759 | |||
| 02e1b9231f | |||
| 4ec4dd2bdb | |||
| aa543160e2 | |||
| 94fa0f04d8 | |||
| 17deb481e0 | |||
| e452ffd38e | |||
| 865b4a53e6 | |||
| c07f3975e9 | |||
| 476505537a | |||
| 74ad5cec90 | |||
| 59a3f7978e | |||
| 7dc976b59e | |||
| 345effee13 | |||
| dee6897931 | |||
| 56f41d70b3 | |||
| 8f570ae8a0 | |||
| e58e24a92d | |||
| 12070bc7b5 | |||
| 37d62c51f3 | |||
| ea9427d46b | |||
| bc77321752 | |||
| 65aa546c1c | |||
| 54484518dc | |||
| 6fe1247d4d | |||
| e59d80a3b3 | |||
| 6c4feba711 | |||
| 006a9af20c | |||
| dfb3b0ac37 | |||
| 44c1a3a928 | |||
| 0c4e28455e | |||
| cfc4cf378f | |||
| a09e69a28b | |||
| 82dd19e274 | |||
| c1d8afdbf7 | |||
| 9b7426f1e6 | |||
| 3c9c865841 | |||
| 8421c9fe46 | |||
| 907e3df156 | |||
| aaa0956148 | |||
| 118019fcf5 | |||
| deb80f4fd0 | |||
| 7d28cea937 | |||
| 2bd5e5c7c5 | |||
| 4d6ac81c59 | |||
| 2ebe0de92d | |||
| f5028ffb60 | |||
| 90016d1217 | |||
| 48d3d1218f | |||
| 4759c4f011 | |||
| 0fbd8d1cdd | |||
| 447cf44d68 | |||
| 82ce17a941 | |||
| 15da996e70 | |||
| 582e19e6a6 | |||
| 79765d6729 | |||
| ffc93eb9d3 | |||
| 1337a4905a | |||
| c7418d9e1a | |||
| 2a94ffd4c9 | |||
| b2fe6caf33 | |||
| 822bbc1957 | |||
| eacddc7ce1 | |||
| dc6ce341bd | |||
| 1aadc93f92 | |||
| 8fdcd479d6 | |||
| d24dde8eff | |||
| 40a34073e9 | |||
| 9ac297c197 | |||
| ddd0662fb8 | |||
| 11bc0dde6c | |||
| 610d691244 | |||
| c88410ea53 | |||
| 9cbdd24281 | |||
| dce1de8c4b | |||
| 86e6c4f600 | |||
| 0618755236 | |||
| b21f3385e1 | |||
| dd61e0c962 | |||
| ac3a42fc41 | |||
| c23f16149c | |||
| 529a4bae00 | |||
| 49606ae007 | |||
| 31a6510d8b | |||
| b5e760ae07 | |||
| ea32babaac | |||
| a4ddedaf46 | |||
| 7ce09c53ca | |||
| 69be2295f1 | |||
| 018efa32f6 | |||
| 2530918dc6 | |||
| 0b09ea1573 | |||
| 21157477b4 | |||
| fcf36e5cd5 | |||
| f5740fa565 | |||
| 4a9fba53a9 | |||
| da61adc9a2 | |||
| 616066ffd0 | |||
| bd5cccb405 | |||
| fbade85cda | |||
| 9060d26f3a | |||
| c889141ec3 | |||
| fb472f353c | |||
| 090bd747e1 | |||
| 4d77a94bbb | |||
| 7f5284b10f | |||
| 9cd5db2d81 | |||
| de0b7d1fe0 | |||
| 4e32745a8f | |||
| 121573de2f | |||
| cd957526e2 | |||
| 7aa5f07731 | |||
| 5b6f7b30c3 | |||
| 18cc21a49e | |||
| 46fa2f6ade | |||
| 0a6315f177 | |||
| 841f99e19d | |||
| 8e9de46cd2 | |||
| 2d44528345 | |||
| 28a38252da | |||
| dfb268bbfc | |||
| 6532c7ff22 | |||
| d2c63cf170 | |||
| 09d66e4528 | |||
| 3078fa9d7b | |||
| 57fbb128e6 | |||
| d73266eeb8 | |||
| 2dbdf2d2b1 | |||
| 383e0adc23 | |||
| d7789f5a44 | |||
| 2638990667 | |||
| c33ecdc26f | |||
| b033d80927 | |||
| cf5d616769 | |||
| 8e722f5ab6 | |||
| 2b75709161 | |||
| c5e2c262b7 | |||
| d10896196d | |||
| 8be1e87bdc | |||
| 96cefe984a | |||
| ca112c3e42 | |||
| 85b6c4fa51 | |||
| ee550e6f25 | |||
| 108a8bb51d | |||
| 3c5b26d1c1 | |||
| 01fbc3db95 | |||
| 8dd9770339 | |||
| 77842647fd | |||
| a309145829 | |||
| 5de8d38b78 | |||
| 2d6dbc552e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ dist_*/
|
||||
**/.claude/settings.local.json
|
||||
.nogit/data/
|
||||
readme.plan.md
|
||||
.playwright-mcp/
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
1007
changelog.md
1007
changelog.md
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,8 @@
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true
|
||||
"production": true,
|
||||
"includeFiles": ["./html/**/*.html"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
60
package.json
60
package.json
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "5.0.2",
|
||||
"version": "11.2.9",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js",
|
||||
"./interfaces": "./dist_ts_interfaces/index.js"
|
||||
"./interfaces": "./dist_ts_interfaces/index.js",
|
||||
"./apiclient": "./dist_ts_apiclient/index.js"
|
||||
},
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
@@ -19,43 +20,46 @@
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsbuild": "^4.3.0",
|
||||
"@git.zone/tsbundle": "^2.9.1",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@types/node": "^25.2.3"
|
||||
"@git.zone/tstest": "^3.3.2",
|
||||
"@git.zone/tswatch": "^3.3.0",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.2.6",
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@api.global/typedserver": "^8.4.2",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.42.0",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@design.estate/dees-catalog": "^3.48.2",
|
||||
"@design.estate/dees-element": "^2.2.3",
|
||||
"@push.rocks/lik": "^6.3.1",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^8.0.0",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdns": "^7.8.0",
|
||||
"@push.rocks/smartacme": "^9.1.3",
|
||||
"@push.rocks/smartdata": "^7.1.0",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartmetrics": "^2.0.10",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartmetrics": "^3.0.2",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartmta": "^5.2.1",
|
||||
"@push.rocks/smartmta": "^5.3.1",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^23.1.2",
|
||||
"@push.rocks/smartproxy": "^25.10.4",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.30",
|
||||
"@push.rocks/smartstate": "^2.2.0",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/catalog": "^2.5.0",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.4.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"lru-cache": "^11.2.6",
|
||||
"uuid": "^13.0.0"
|
||||
@@ -93,5 +97,19 @@
|
||||
"puppeteer"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0"
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"ts_apiclient/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"dist_ts_apiclient/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
]
|
||||
}
|
||||
|
||||
4596
pnpm-lock.yaml
generated
4596
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt`
|
||||
### SmartProxy v23.1.2 Route Validation
|
||||
- SmartProxy 23.1.2 enforces stricter route validation
|
||||
- Forward actions MUST use `targets` (array) instead of `target` (singular)
|
||||
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid `/etc/dcrouter` permission errors
|
||||
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid starting a real MongoDB process in tests
|
||||
|
||||
```typescript
|
||||
// WRONG - will fail validation
|
||||
@@ -693,7 +693,7 @@ The configuration UI has been converted from an editable interface to a read-onl
|
||||
## Smartdata Cache System (2026-02-03)
|
||||
|
||||
### Overview
|
||||
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `/etc/dcrouter/tsmdb`.
|
||||
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `~/.serve.zone/dcrouter/tsmdb`.
|
||||
|
||||
### Technology Stack
|
||||
| Layer | Package | Purpose |
|
||||
@@ -747,7 +747,7 @@ await email.delete();
|
||||
const dcRouter = new DcRouter({
|
||||
cacheConfig: {
|
||||
enabled: true,
|
||||
storagePath: '/etc/dcrouter/tsmdb',
|
||||
storagePath: '~/.serve.zone/dcrouter/tsmdb',
|
||||
dbName: 'dcrouter',
|
||||
cleanupIntervalHours: 1,
|
||||
ttlConfig: {
|
||||
|
||||
383
readme.md
383
readme.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
||||
|
||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, and RADIUS protocols. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, and enterprise-grade email infrastructure — all from a single process.
|
||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -21,9 +21,12 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- [Email System](#email-system)
|
||||
- [DNS Server](#dns-server)
|
||||
- [RADIUS Server](#radius-server)
|
||||
- [Remote Ingress](#remote-ingress)
|
||||
- [Certificate Management](#certificate-management)
|
||||
- [Storage & Caching](#storage--caching)
|
||||
- [Security Features](#security-features)
|
||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||
- [API Client](#api-client)
|
||||
- [API Reference](#api-reference)
|
||||
- [Sub-Modules](#sub-modules)
|
||||
- [Testing](#testing)
|
||||
@@ -46,7 +49,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **Hierarchical rate limiting** — global, per-domain, per-sender
|
||||
|
||||
### 🔒 Enterprise Security
|
||||
- **Automatic TLS certificates** via ACME with Cloudflare DNS-01 challenges
|
||||
- **Automatic TLS certificates** via ACME (smartacme v9) with Cloudflare DNS-01 challenges
|
||||
- **Smart certificate scheduling** — per-domain deduplication, controlled parallelism, and account rate limiting handled automatically
|
||||
- **Per-domain exponential backoff** — failed provisioning attempts are tracked and backed off to avoid hammering ACME servers
|
||||
- **IP reputation checking** with caching and configurable thresholds
|
||||
- **Content scanning** for spam, viruses, and malicious attachments
|
||||
- **Security event logging** with structured audit trails
|
||||
@@ -57,6 +62,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **RADIUS accounting** for session tracking, traffic metering, and billing
|
||||
- **Real-time management** via OpsServer API
|
||||
|
||||
### 🌍 Remote Ingress (powered by [remoteingress](https://code.foss.global/serve.zone/remoteingress))
|
||||
- **Distributed edge networking** — accept traffic at remote edge nodes and tunnel it to the hub
|
||||
- **Edge registration CRUD** with secret-based authentication
|
||||
- **Auto-derived ports** — edges automatically pick up ports from routes tagged with `remoteIngress.enabled`
|
||||
- **Connection tokens** — generate a single opaque base64url token containing hubHost, hubPort, edgeId, and secret for easy edge provisioning
|
||||
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
||||
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
||||
|
||||
### ⚡ High Performance
|
||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||
@@ -73,9 +86,18 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
### 🖥️ OpsServer Dashboard
|
||||
- **Web-based management interface** with real-time monitoring
|
||||
- **JWT authentication** with session persistence
|
||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, and security events
|
||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
|
||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||
- **Remote ingress management** with connection token generation and one-click copy
|
||||
- **Read-only configuration display** — DcRouter is configured through code
|
||||
|
||||
### 🔧 Programmatic API Client
|
||||
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
|
||||
- **Builder pattern** — fluent `.setName().setMatch().save()` chains for creating routes, tokens, and edges
|
||||
- **Auto-injected auth** — JWT identity and API tokens included automatically in every request
|
||||
- **Dual auth modes** — login with credentials (JWT) or pass an API token for programmatic access
|
||||
- **Full coverage** — wraps every OpsServer endpoint with typed request/response pairs
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -215,11 +237,18 @@ const router = new DcRouter({
|
||||
accounting: { enabled: true, retentionDays: 30 }
|
||||
},
|
||||
|
||||
// Remote Ingress — edge nodes tunnel traffic to this hub
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'hub.example.com',
|
||||
},
|
||||
|
||||
// Persistent storage
|
||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
||||
|
||||
// Cache database
|
||||
cacheConfig: { enabled: true, storagePath: '/etc/dcrouter/tsmdb' },
|
||||
cacheConfig: { enabled: true, storagePath: '~/.serve.zone/dcrouter/tsmdb' },
|
||||
|
||||
// TLS & ACME
|
||||
tls: { contactEmail: 'admin@example.com' },
|
||||
@@ -242,6 +271,7 @@ graph TB
|
||||
TCP[TCP Clients]
|
||||
DNS[DNS Queries]
|
||||
RAD[RADIUS Clients]
|
||||
EDGE[Edge Nodes]
|
||||
end
|
||||
|
||||
subgraph "DcRouter Core"
|
||||
@@ -250,7 +280,8 @@ graph TB
|
||||
ES[smartmta Email Server<br/><i>TypeScript + Rust</i>]
|
||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||
RS[SmartRadius Server]
|
||||
CM[Certificate Manager]
|
||||
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||
OS[OpsServer Dashboard]
|
||||
MM[Metrics Manager]
|
||||
SM[Storage Manager]
|
||||
@@ -269,11 +300,13 @@ graph TB
|
||||
SMTP --> ES
|
||||
DNS --> DS
|
||||
RAD --> RS
|
||||
EDGE --> RI
|
||||
|
||||
DC --> SP
|
||||
DC --> ES
|
||||
DC --> DS
|
||||
DC --> RS
|
||||
DC --> RI
|
||||
DC --> CM
|
||||
DC --> OS
|
||||
DC --> MM
|
||||
@@ -284,6 +317,7 @@ graph TB
|
||||
SP --> API
|
||||
ES --> MAIL
|
||||
ES --> DB
|
||||
RI --> SP
|
||||
|
||||
CM -.-> SP
|
||||
CM -.-> ES
|
||||
@@ -297,7 +331,9 @@ graph TB
|
||||
| **SmartProxy** | `@push.rocks/smartproxy` | High-performance HTTP/HTTPS and TCP/SNI proxy with route-based config (Rust engine) |
|
||||
| **UnifiedEmailServer** | `@push.rocks/smartmta` | Full SMTP server with pattern-based routing, DKIM, queue management (TypeScript + Rust) |
|
||||
| **DNS Server** | `@push.rocks/smartdns` | Authoritative DNS with dynamic records and DKIM TXT auto-generation (Rust engine) |
|
||||
| **SmartAcme** | `@push.rocks/smartacme` | ACME certificate management with per-domain dedup, concurrency control, and rate limiting |
|
||||
| **RADIUS Server** | `@push.rocks/smartradius` | Network authentication with MAB, VLAN assignment, and accounting |
|
||||
| **RemoteIngress** | `@serve.zone/remoteingress` | Distributed edge tunneling with Rust data plane and TS management |
|
||||
| **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) |
|
||||
@@ -307,19 +343,20 @@ graph TB
|
||||
|
||||
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
||||
|
||||
1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, and SmartRadius based on which configs are provided.
|
||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery.
|
||||
3. **On `stop()`**: All services are gracefully shut down in reverse order.
|
||||
1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
|
||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
||||
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
||||
|
||||
### Rust-Powered Architecture
|
||||
|
||||
DcRouter itself is a pure TypeScript orchestrator, but three of its core sub-components ship with **compiled Rust binaries** for performance-critical paths. At runtime each package detects the platform, unpacks the correct binary, and communicates with TypeScript over IPC/FFI — so you get the ergonomics of TypeScript with the throughput of native code.
|
||||
DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-components ship with **compiled Rust binaries** for performance-critical paths. At runtime each package detects the platform, unpacks the correct binary, and communicates with TypeScript over IPC/FFI — so you get the ergonomics of TypeScript with the throughput of native code.
|
||||
|
||||
| Component | Rust Binary | What It Handles |
|
||||
|-----------|-------------|-----------------|
|
||||
| **SmartProxy** | `smartproxy-bin` | All TCP/TLS/HTTP proxy networking, NFTables integration, connection metrics |
|
||||
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
|
||||
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
||||
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
||||
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
||||
|
||||
## Configuration Reference
|
||||
@@ -328,6 +365,10 @@ DcRouter itself is a pure TypeScript orchestrator, but three of its core sub-com
|
||||
|
||||
```typescript
|
||||
interface IDcRouterOptions {
|
||||
// ── Base ───────────────────────────────────────────────────────
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
baseDir?: string;
|
||||
|
||||
// ── Traffic Routing ────────────────────────────────────────────
|
||||
/** SmartProxy config for HTTP/HTTPS and TCP/SNI routing */
|
||||
smartProxyConfig?: ISmartProxyOptions;
|
||||
@@ -371,6 +412,18 @@ interface IDcRouterOptions {
|
||||
accounting?: { enabled: boolean; retentionDays?: number };
|
||||
};
|
||||
|
||||
// ── Remote Ingress ─────────────────────────────────────────────
|
||||
/** Remote Ingress hub for edge tunnel connections */
|
||||
remoteIngressConfig?: {
|
||||
enabled?: boolean; // default: false
|
||||
tunnelPort?: number; // default: 8443
|
||||
hubDomain?: string; // External hostname for connection tokens
|
||||
tls?: {
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── TLS & Certificates ────────────────────────────────────────
|
||||
tls?: {
|
||||
contactEmail: string;
|
||||
@@ -388,7 +441,7 @@ interface IDcRouterOptions {
|
||||
};
|
||||
cacheConfig?: {
|
||||
enabled?: boolean; // default: true
|
||||
storagePath?: string; // default: '/etc/dcrouter/tsmdb'
|
||||
storagePath?: string; // default: '~/.serve.zone/dcrouter/tsmdb'
|
||||
dbName?: string; // default: 'dcrouter'
|
||||
cleanupIntervalHours?: number; // default: 1
|
||||
ttlConfig?: {
|
||||
@@ -584,15 +637,6 @@ match: { sizeRange: { min: 1000, max: 5000000 }, hasAttachments: true }
|
||||
match: { subject: /invoice|receipt/i }
|
||||
```
|
||||
|
||||
### Socket-Handler Mode 🔌
|
||||
|
||||
When `useSocketHandler: true` is set, SmartProxy passes sockets directly to the email server — no internal port binding, lower latency, and fewer open ports:
|
||||
|
||||
```
|
||||
Traditional: External Port → SmartProxy → Internal Port → Email Server
|
||||
Socket Mode: External Port → SmartProxy → (direct socket) → Email Server
|
||||
```
|
||||
|
||||
### Email Security Stack
|
||||
|
||||
- **DKIM** — Automatic key generation, signing, and rotation for all domains
|
||||
@@ -705,6 +749,175 @@ RADIUS is fully manageable at runtime via the OpsServer API:
|
||||
- Session monitoring and forced disconnects
|
||||
- Accounting summaries and statistics
|
||||
|
||||
## Remote Ingress
|
||||
|
||||
DcRouter can act as a **hub** for distributed edge nodes using [`@serve.zone/remoteingress`](https://code.foss.global/serve.zone/remoteingress). Edge nodes accept incoming traffic at remote locations and tunnel it back to the hub over a single multiplexed connection. This is ideal for scenarios where you need to accept traffic at multiple geographic locations but process it centrally.
|
||||
|
||||
### Enabling Remote Ingress
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'hub.example.com', // Embedded in connection tokens
|
||||
},
|
||||
// Routes tagged with remoteIngress are auto-derived to edge listen ports
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-via-edge',
|
||||
match: { domains: ['app.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.10', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
remoteIngress: { enabled: true } // Edges will listen on port 443
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await router.start();
|
||||
```
|
||||
|
||||
### Edge Registration
|
||||
|
||||
Edges are registered via the OpsServer API (or dashboard UI). Each edge gets a unique ID and secret:
|
||||
|
||||
```typescript
|
||||
// Via TypedRequest API
|
||||
const createReq = new TypedRequest<IReq_CreateRemoteIngress>(
|
||||
'https://hub:3000/typedrequest', 'createRemoteIngress'
|
||||
);
|
||||
const { edge } = await createReq.fire({
|
||||
identity,
|
||||
name: 'edge-nyc-01',
|
||||
autoDerivePorts: true,
|
||||
tags: ['us-east'],
|
||||
});
|
||||
// edge.secret is returned only on creation — save it!
|
||||
```
|
||||
|
||||
### Connection Tokens 🔑
|
||||
|
||||
Instead of configuring edges with four separate values (hubHost, hubPort, edgeId, secret), DcRouter can generate a single **connection token** — an opaque base64url string that encodes everything:
|
||||
|
||||
```typescript
|
||||
// Via TypedRequest API
|
||||
const tokenReq = new TypedRequest<IReq_GetRemoteIngressConnectionToken>(
|
||||
'https://hub:3000/typedrequest', 'getRemoteIngressConnectionToken'
|
||||
);
|
||||
const { token } = await tokenReq.fire({ identity, edgeId: 'edge-uuid' });
|
||||
// token = "eyJoIjoiaHViLmV4YW1wbGUuY29tIiwicCI6ODQ0MywiZSI6I..."
|
||||
|
||||
// On the edge side, just pass the token:
|
||||
const edge = new RemoteIngressEdge({ token });
|
||||
await edge.start();
|
||||
```
|
||||
|
||||
The token is generated using `remoteingress.encodeConnectionToken()` and contains `{ hubHost, hubPort, edgeId, secret }`. The `hubHost` comes from `remoteIngressConfig.hubDomain` (or can be overridden per-request).
|
||||
|
||||
In the OpsServer dashboard, click **"Copy Token"** on any edge row to copy the connection token to your clipboard.
|
||||
|
||||
### Auto-Derived Ports
|
||||
|
||||
When routes have `remoteIngress: { enabled: true }`, edges with `autoDerivePorts: true` (default) automatically pick up those routes' ports. You can also use `edgeFilter` to restrict which edges get which ports:
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'web-route',
|
||||
match: { ports: [443] },
|
||||
action: { /* ... */ },
|
||||
remoteIngress: {
|
||||
enabled: true,
|
||||
edgeFilter: ['us-east', 'edge-uuid-123'] // Only edges with matching id or tags
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard Actions
|
||||
|
||||
The OpsServer Remote Ingress view provides:
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| **Create Edge Node** | Register a new edge with name, ports, tags |
|
||||
| **Enable / Disable** | Toggle an edge on or off |
|
||||
| **Edit** | Modify name, manual ports, auto-derive setting, tags |
|
||||
| **Regenerate Secret** | Issue a new secret (invalidates the old one) |
|
||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||
| **Delete** | Remove the edge registration |
|
||||
|
||||
## Certificate Management
|
||||
|
||||
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
||||
|
||||
### How It Works
|
||||
|
||||
When a `dnsChallenge` is configured (e.g. with a Cloudflare API key), DcRouter creates a SmartAcme instance that handles DNS-01 challenges for automatic certificate provisioning. SmartProxy calls the `certProvisionFunction` whenever a route needs a TLS certificate, and SmartAcme takes care of the rest.
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'secure-app',
|
||||
match: { domains: ['app.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.10', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' } // ← triggers ACME provisioning
|
||||
}
|
||||
}
|
||||
],
|
||||
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
|
||||
},
|
||||
tls: { contactEmail: 'admin@example.com' },
|
||||
dnsChallenge: { cloudflareApiKey: process.env.CLOUDFLARE_API_KEY }
|
||||
});
|
||||
```
|
||||
|
||||
### smartacme v9 Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Per-domain deduplication** | Concurrent requests for the same domain share a single ACME operation |
|
||||
| **Global concurrency cap** | Default 5 parallel ACME operations to prevent overload |
|
||||
| **Account rate limiting** | Sliding window (250 orders / 3 hours) to stay within ACME provider limits |
|
||||
| **Structured errors** | `AcmeError` with `isRetryable`, `isRateLimited`, `retryAfter` fields |
|
||||
| **Clean shutdown** | `stop()` properly destroys HTTP agents and DNS clients |
|
||||
|
||||
### Per-Domain Backoff
|
||||
|
||||
DcRouter's `CertProvisionScheduler` adds **per-domain exponential backoff** on top of smartacme's built-in protections. If a DNS-01 challenge fails for a domain:
|
||||
|
||||
1. The failure is recorded (persisted to storage)
|
||||
2. The domain enters backoff: `min(failures² × 1 hour, 24 hours)`
|
||||
3. Subsequent requests for that domain are rejected until the backoff expires
|
||||
4. On success, the backoff is cleared
|
||||
|
||||
This prevents hammering ACME servers for domains with persistent issues (e.g. missing DNS delegation).
|
||||
|
||||
### Fallback to HTTP-01
|
||||
|
||||
If DNS-01 fails, the `certProvisionFunction` returns `'http01'` to tell SmartProxy to fall back to HTTP-01 challenge validation. This provides a safety net for domains where DNS-01 isn't viable.
|
||||
|
||||
### Certificate Storage
|
||||
|
||||
Certificates are persisted via the `StorageBackedCertManager` which uses DcRouter's `StorageManager`. This means certs survive restarts and don't need to be re-provisioned unless they expire.
|
||||
|
||||
### Dashboard
|
||||
|
||||
The OpsServer includes a **Certificates** view showing:
|
||||
- All domains with their certificate status (valid, expiring, expired, failed)
|
||||
- Certificate source (ACME, provision function, static)
|
||||
- Expiry dates and issuer information
|
||||
- Backoff status for failed domains
|
||||
- One-click reprovisioning per domain
|
||||
- Certificate import and export
|
||||
|
||||
## Storage & Caching
|
||||
|
||||
### StorageManager
|
||||
@@ -725,7 +938,7 @@ storage: {
|
||||
// Simply omit the storage config
|
||||
```
|
||||
|
||||
Used for: DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs.
|
||||
Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs, cert backoff state, remote ingress edge registrations.
|
||||
|
||||
### Cache Database
|
||||
|
||||
@@ -734,7 +947,7 @@ An embedded MongoDB-compatible database (via smartdata + LocalTsmDb) for persist
|
||||
```typescript
|
||||
cacheConfig: {
|
||||
enabled: true,
|
||||
storagePath: '/etc/dcrouter/tsmdb',
|
||||
storagePath: '~/.serve.zone/dcrouter/tsmdb',
|
||||
dbName: 'dcrouter',
|
||||
cleanupIntervalHours: 1,
|
||||
ttlConfig: {
|
||||
@@ -811,6 +1024,8 @@ The OpsServer provides a web-based management interface served on port 3000. It'
|
||||
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
||||
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
||||
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
||||
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
||||
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
||||
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
||||
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
||||
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
||||
@@ -831,18 +1046,49 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
||||
'getCombinedMetrics' // All metrics in one call
|
||||
|
||||
// Email Operations
|
||||
'getQueuedEmails' // Emails pending delivery
|
||||
'getSentEmails' // Successfully delivered emails
|
||||
'getFailedEmails' // Failed emails
|
||||
'getAllEmails' // List all emails (queued/sent/failed)
|
||||
'getEmailDetail' // Full detail for a specific email
|
||||
'resendEmail' // Re-queue a failed email
|
||||
'getBounceRecords' // Bounce records
|
||||
'removeFromSuppressionList' // Unsuppress an address
|
||||
|
||||
// Certificates
|
||||
'getCertificateOverview' // Domain-centric certificate status
|
||||
'reprovisionCertificate' // Reprovision by route name (legacy)
|
||||
'reprovisionCertificateDomain' // Reprovision by domain (preferred)
|
||||
'importCertificate' // Import a certificate
|
||||
'exportCertificate' // Export a certificate
|
||||
'deleteCertificate' // Delete a certificate
|
||||
|
||||
// Remote Ingress
|
||||
'getRemoteIngresses' // List all edge registrations
|
||||
'createRemoteIngress' // Register a new edge
|
||||
'updateRemoteIngress' // Update edge settings
|
||||
'deleteRemoteIngress' // Remove an edge
|
||||
'regenerateRemoteIngressSecret' // Issue a new secret
|
||||
'getRemoteIngressStatus' // Runtime status of all edges
|
||||
'getRemoteIngressConnectionToken' // Generate a connection token for an edge
|
||||
|
||||
// Route Management (JWT or API token auth)
|
||||
'getMergedRoutes' // List all routes (hardcoded + programmatic)
|
||||
'createRoute' // Create a new programmatic route
|
||||
'updateRoute' // Update a programmatic route
|
||||
'deleteRoute' // Delete a programmatic route
|
||||
'toggleRoute' // Enable/disable a programmatic route
|
||||
'setRouteOverride' // Override a hardcoded route
|
||||
'removeRouteOverride' // Remove a hardcoded route override
|
||||
|
||||
// API Token Management (admin JWT only)
|
||||
'createApiToken' // Create API token → returns raw value once
|
||||
'listApiTokens' // List all tokens (without secrets)
|
||||
'revokeApiToken' // Delete an API token
|
||||
'rollApiToken' // Regenerate token secret
|
||||
'toggleApiToken' // Enable/disable a token
|
||||
|
||||
// Configuration (read-only)
|
||||
'getConfiguration' // Current system config
|
||||
|
||||
// Logs
|
||||
'getLogs' // Retrieve system logs
|
||||
'getRecentLogs' // Retrieve system logs with filtering
|
||||
'getLogStream' // Stream live logs
|
||||
|
||||
// RADIUS
|
||||
'getRadiusSessions' // Active RADIUS sessions
|
||||
@@ -856,6 +1102,77 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
||||
'testVlanAssignment' // Test what VLAN a MAC gets
|
||||
```
|
||||
|
||||
## API Client
|
||||
|
||||
DcRouter ships with a typed, object-oriented API client for programmatic management of a running instance. Install it separately or import from the main package:
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
# or import from the main package:
|
||||
# import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
```
|
||||
|
||||
### Quick Example
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
|
||||
await client.login('admin', 'password');
|
||||
|
||||
// OO resource instances with methods
|
||||
const { routes } = await client.routes.list();
|
||||
await routes[0].toggle(false);
|
||||
|
||||
// Builder pattern for creation
|
||||
const newRoute = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
||||
.setTls({ mode: 'terminate', certificate: 'auto' })
|
||||
.save();
|
||||
|
||||
// Manage certificates
|
||||
const { certificates, summary } = await client.certificates.list();
|
||||
await certificates[0].reprovision();
|
||||
|
||||
// Create API tokens with builder
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('ci-token')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.setExpiresInDays(90)
|
||||
.save();
|
||||
console.log(token.tokenValue); // only available at creation
|
||||
|
||||
// Remote ingress edges
|
||||
const edge = await client.remoteIngress.build()
|
||||
.setName('edge-nyc-01')
|
||||
.setListenPorts([80, 443])
|
||||
.save();
|
||||
const connToken = await edge.getConnectionToken();
|
||||
|
||||
// Read-only managers
|
||||
const health = await client.stats.getHealth();
|
||||
const config = await client.config.get();
|
||||
const { logs } = await client.logs.getRecent({ level: 'error', limit: 50 });
|
||||
```
|
||||
|
||||
### Resource Managers
|
||||
|
||||
| Manager | Operations |
|
||||
|---------|-----------|
|
||||
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
|
||||
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
|
||||
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
|
||||
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
|
||||
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
|
||||
| `client.config` | `get(section?)` |
|
||||
| `client.logs` | `getRecent()`, `getStream()` |
|
||||
| `client.emails` | `list()` → Email: `getDetail()`, `resend()` |
|
||||
| `client.radius` | `.clients`, `.vlans`, `.sessions` sub-managers + `getStatistics()`, `getAccountingSummary()` |
|
||||
|
||||
See the [full API client documentation](./ts_apiclient/readme.md) for detailed usage of every manager, builder, and resource class.
|
||||
|
||||
## API Reference
|
||||
|
||||
### DcRouter Class
|
||||
@@ -884,13 +1201,18 @@ const router = new DcRouter(options: IDcRouterOptions);
|
||||
|----------|------|-------------|
|
||||
| `options` | `IDcRouterOptions` | Current configuration |
|
||||
| `smartProxy` | `SmartProxy` | SmartProxy instance |
|
||||
| `smartAcme` | `SmartAcme` | SmartAcme v9 certificate manager instance |
|
||||
| `emailServer` | `UnifiedEmailServer` | Email server instance (from smartmta) |
|
||||
| `dnsServer` | `DnsServer` | DNS server instance |
|
||||
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
||||
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
||||
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
||||
| `storageManager` | `StorageManager` | Storage backend |
|
||||
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
||||
| `metricsManager` | `MetricsManager` | Metrics collector |
|
||||
| `cacheDb` | `CacheDb` | Cache database instance |
|
||||
| `certProvisionScheduler` | `CertProvisionScheduler` | Per-domain backoff scheduler for cert provisioning |
|
||||
| `certificateStatusMap` | `Map<string, ...>` | Domain-keyed certificate status from SmartProxy events |
|
||||
|
||||
### Re-exported Types
|
||||
|
||||
@@ -915,12 +1237,14 @@ DcRouter is published as a monorepo with separately-installable interface and we
|
||||
|---------|-------------|---------|
|
||||
| [`@serve.zone/dcrouter`](https://www.npmjs.com/package/@serve.zone/dcrouter) | Main package — the full router | `pnpm add @serve.zone/dcrouter` |
|
||||
| [`@serve.zone/dcrouter-interfaces`](https://www.npmjs.com/package/@serve.zone/dcrouter-interfaces) | TypedRequest interfaces for the OpsServer API | `pnpm add @serve.zone/dcrouter-interfaces` |
|
||||
| [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) | OO API client with builder pattern | `pnpm add @serve.zone/dcrouter-apiclient` |
|
||||
| [`@serve.zone/dcrouter-web`](https://www.npmjs.com/package/@serve.zone/dcrouter-web) | Web dashboard components | `pnpm add @serve.zone/dcrouter-web` |
|
||||
|
||||
You can also import interfaces directly from the main package:
|
||||
You can also import directly from the main package:
|
||||
|
||||
```typescript
|
||||
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -928,7 +1252,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
DcRouter includes a comprehensive test suite covering all system components:
|
||||
|
||||
```bash
|
||||
# Run all tests (10 files, 73 tests)
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run a specific test file
|
||||
@@ -942,6 +1266,7 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
|
||||
|
||||
| Test File | Area | Tests |
|
||||
|-----------|------|-------|
|
||||
| `test.apiclient.ts` | API client instantiation, builders, resource hydration, exports | 18 |
|
||||
| `test.contentscanner.ts` | Content scanning (spam, phishing, malware, attachments) | 13 |
|
||||
| `test.dcrouter.email.ts` | Email config, domain and route setup | 4 |
|
||||
| `test.dns-server-config.ts` | DNS record parsing, grouping, extraction | 5 |
|
||||
|
||||
376
test/test.apiclient.ts
Normal file
376
test/test.apiclient.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
DcRouterApiClient,
|
||||
Route,
|
||||
RouteBuilder,
|
||||
RouteManager,
|
||||
Certificate,
|
||||
CertificateManager,
|
||||
ApiToken,
|
||||
ApiTokenBuilder,
|
||||
ApiTokenManager,
|
||||
RemoteIngress,
|
||||
RemoteIngressBuilder,
|
||||
RemoteIngressManager,
|
||||
Email,
|
||||
EmailManager,
|
||||
StatsManager,
|
||||
ConfigManager,
|
||||
LogManager,
|
||||
RadiusManager,
|
||||
RadiusClientManager,
|
||||
RadiusVlanManager,
|
||||
RadiusSessionManager,
|
||||
} from '../ts_apiclient/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// Instantiation & Structure
|
||||
// =============================================================================
|
||||
|
||||
tap.test('DcRouterApiClient - should instantiate with baseUrl', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
expect(client).toBeTruthy();
|
||||
expect(client.baseUrl).toEqual('https://localhost:3000');
|
||||
expect(client.identity).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - should strip trailing slashes from baseUrl', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000///' });
|
||||
expect(client.baseUrl).toEqual('https://localhost:3000');
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - should accept optional apiToken', async () => {
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://localhost:3000',
|
||||
apiToken: 'dcr_test_token',
|
||||
});
|
||||
expect(client.apiToken).toEqual('dcr_test_token');
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - should have all resource managers', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
expect(client.routes).toBeInstanceOf(RouteManager);
|
||||
expect(client.certificates).toBeInstanceOf(CertificateManager);
|
||||
expect(client.apiTokens).toBeInstanceOf(ApiTokenManager);
|
||||
expect(client.remoteIngress).toBeInstanceOf(RemoteIngressManager);
|
||||
expect(client.stats).toBeInstanceOf(StatsManager);
|
||||
expect(client.config).toBeInstanceOf(ConfigManager);
|
||||
expect(client.logs).toBeInstanceOf(LogManager);
|
||||
expect(client.emails).toBeInstanceOf(EmailManager);
|
||||
expect(client.radius).toBeInstanceOf(RadiusManager);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// buildRequestPayload
|
||||
// =============================================================================
|
||||
|
||||
tap.test('DcRouterApiClient - buildRequestPayload includes identity when set', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const identity = {
|
||||
jwt: 'test-jwt',
|
||||
userId: 'user1',
|
||||
name: 'Admin',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
client.identity = identity;
|
||||
|
||||
const payload = client.buildRequestPayload({ extra: 'data' });
|
||||
expect(payload.identity).toEqual(identity);
|
||||
expect(payload.extra).toEqual('data');
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - buildRequestPayload includes apiToken when set', async () => {
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://localhost:3000',
|
||||
apiToken: 'dcr_abc123',
|
||||
});
|
||||
|
||||
const payload = client.buildRequestPayload();
|
||||
expect(payload.apiToken).toEqual('dcr_abc123');
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - buildRequestPayload with both identity and apiToken', async () => {
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://localhost:3000',
|
||||
apiToken: 'dcr_abc123',
|
||||
});
|
||||
client.identity = {
|
||||
jwt: 'test-jwt',
|
||||
userId: 'user1',
|
||||
name: 'Admin',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
const payload = client.buildRequestPayload({ foo: 'bar' });
|
||||
expect(payload.identity).toBeTruthy();
|
||||
expect(payload.apiToken).toEqual('dcr_abc123');
|
||||
expect(payload.foo).toEqual('bar');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Route Builder
|
||||
// =============================================================================
|
||||
|
||||
tap.test('RouteBuilder - should support fluent builder pattern', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const builder = client.routes.build();
|
||||
expect(builder).toBeInstanceOf(RouteBuilder);
|
||||
|
||||
// Fluent methods return `this` (same reference)
|
||||
const result = builder
|
||||
.setName('test-route')
|
||||
.setMatch({ ports: 443, domains: 'example.com' })
|
||||
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
||||
.setEnabled(true);
|
||||
|
||||
expect(result === builder).toBeTrue();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ApiToken Builder
|
||||
// =============================================================================
|
||||
|
||||
tap.test('ApiTokenBuilder - should support fluent builder pattern', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const builder = client.apiTokens.build();
|
||||
expect(builder).toBeInstanceOf(ApiTokenBuilder);
|
||||
|
||||
const result = builder
|
||||
.setName('ci-token')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.addScope('config:read')
|
||||
.setExpiresInDays(30);
|
||||
|
||||
expect(result === builder).toBeTrue();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// RemoteIngress Builder
|
||||
// =============================================================================
|
||||
|
||||
tap.test('RemoteIngressBuilder - should support fluent builder pattern', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const builder = client.remoteIngress.build();
|
||||
expect(builder).toBeInstanceOf(RemoteIngressBuilder);
|
||||
|
||||
const result = builder
|
||||
.setName('edge-1')
|
||||
.setListenPorts([80, 443])
|
||||
.setAutoDerivePorts(true)
|
||||
.setTags(['production']);
|
||||
|
||||
expect(result === builder).toBeTrue();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Route resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('Route - should hydrate from IMergedRoute data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const route = new Route(client, {
|
||||
route: {
|
||||
name: 'test-route',
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
||||
},
|
||||
source: 'programmatic',
|
||||
enabled: true,
|
||||
overridden: false,
|
||||
storedRouteId: 'route-123',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
});
|
||||
|
||||
expect(route.name).toEqual('test-route');
|
||||
expect(route.source).toEqual('programmatic');
|
||||
expect(route.enabled).toEqual(true);
|
||||
expect(route.overridden).toEqual(false);
|
||||
expect(route.storedRouteId).toEqual('route-123');
|
||||
expect(route.routeConfig.match.ports).toEqual(443);
|
||||
});
|
||||
|
||||
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const route = new Route(client, {
|
||||
route: {
|
||||
name: 'hardcoded-route',
|
||||
match: { ports: 80 },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
|
||||
},
|
||||
source: 'hardcoded',
|
||||
enabled: true,
|
||||
overridden: false,
|
||||
// No storedRouteId for hardcoded routes
|
||||
});
|
||||
|
||||
let updateError: Error | undefined;
|
||||
try {
|
||||
await route.update({ name: 'new-name' });
|
||||
} catch (e) {
|
||||
updateError = e as Error;
|
||||
}
|
||||
expect(updateError).toBeTruthy();
|
||||
expect(updateError!.message).toInclude('hardcoded');
|
||||
|
||||
let deleteError: Error | undefined;
|
||||
try {
|
||||
await route.delete();
|
||||
} catch (e) {
|
||||
deleteError = e as Error;
|
||||
}
|
||||
expect(deleteError).toBeTruthy();
|
||||
|
||||
let toggleError: Error | undefined;
|
||||
try {
|
||||
await route.toggle(false);
|
||||
} catch (e) {
|
||||
toggleError = e as Error;
|
||||
}
|
||||
expect(toggleError).toBeTruthy();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Certificate resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('Certificate - should hydrate from ICertificateInfo data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const cert = new Certificate(client, {
|
||||
domain: 'example.com',
|
||||
routeNames: ['main-route'],
|
||||
status: 'valid',
|
||||
source: 'acme',
|
||||
tlsMode: 'terminate',
|
||||
expiryDate: '2027-01-01T00:00:00Z',
|
||||
issuer: "Let's Encrypt",
|
||||
canReprovision: true,
|
||||
});
|
||||
|
||||
expect(cert.domain).toEqual('example.com');
|
||||
expect(cert.status).toEqual('valid');
|
||||
expect(cert.source).toEqual('acme');
|
||||
expect(cert.canReprovision).toEqual(true);
|
||||
expect(cert.routeNames.length).toEqual(1);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ApiToken resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('ApiToken - should hydrate from IApiTokenInfo data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const token = new ApiToken(
|
||||
client,
|
||||
{
|
||||
id: 'token-1',
|
||||
name: 'ci-token',
|
||||
scopes: ['routes:read', 'routes:write'],
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
},
|
||||
'dcr_secret_value',
|
||||
);
|
||||
|
||||
expect(token.id).toEqual('token-1');
|
||||
expect(token.name).toEqual('ci-token');
|
||||
expect(token.scopes.length).toEqual(2);
|
||||
expect(token.enabled).toEqual(true);
|
||||
expect(token.tokenValue).toEqual('dcr_secret_value');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// RemoteIngress resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('RemoteIngress - should hydrate from IRemoteIngress data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const edge = new RemoteIngress(client, {
|
||||
id: 'edge-1',
|
||||
name: 'test-edge',
|
||||
secret: 'secret123',
|
||||
listenPorts: [80, 443],
|
||||
enabled: true,
|
||||
autoDerivePorts: true,
|
||||
tags: ['prod'],
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
effectiveListenPorts: [80, 443, 8080],
|
||||
manualPorts: [80, 443],
|
||||
derivedPorts: [8080],
|
||||
});
|
||||
|
||||
expect(edge.id).toEqual('edge-1');
|
||||
expect(edge.name).toEqual('test-edge');
|
||||
expect(edge.listenPorts.length).toEqual(2);
|
||||
expect(edge.effectiveListenPorts!.length).toEqual(3);
|
||||
expect(edge.autoDerivePorts).toEqual(true);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Email resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('Email - should hydrate from IEmail data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const email = new Email(client, {
|
||||
id: 'email-1',
|
||||
direction: 'inbound',
|
||||
status: 'delivered',
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test email',
|
||||
timestamp: '2026-03-06T00:00:00Z',
|
||||
messageId: '<msg-1@example.com>',
|
||||
size: '1234',
|
||||
});
|
||||
|
||||
expect(email.id).toEqual('email-1');
|
||||
expect(email.direction).toEqual('inbound');
|
||||
expect(email.status).toEqual('delivered');
|
||||
expect(email.from).toEqual('sender@example.com');
|
||||
expect(email.subject).toEqual('Test email');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// RadiusManager structure
|
||||
// =============================================================================
|
||||
|
||||
tap.test('RadiusManager - should have sub-managers', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
expect(client.radius.clients).toBeInstanceOf(RadiusClientManager);
|
||||
expect(client.radius.vlans).toBeInstanceOf(RadiusVlanManager);
|
||||
expect(client.radius.sessions).toBeInstanceOf(RadiusSessionManager);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Exports verification
|
||||
// =============================================================================
|
||||
|
||||
tap.test('Exports - all expected classes should be importable', async () => {
|
||||
expect(DcRouterApiClient).toBeTruthy();
|
||||
expect(Route).toBeTruthy();
|
||||
expect(RouteBuilder).toBeTruthy();
|
||||
expect(RouteManager).toBeTruthy();
|
||||
expect(Certificate).toBeTruthy();
|
||||
expect(CertificateManager).toBeTruthy();
|
||||
expect(ApiToken).toBeTruthy();
|
||||
expect(ApiTokenBuilder).toBeTruthy();
|
||||
expect(ApiTokenManager).toBeTruthy();
|
||||
expect(RemoteIngress).toBeTruthy();
|
||||
expect(RemoteIngressBuilder).toBeTruthy();
|
||||
expect(RemoteIngressManager).toBeTruthy();
|
||||
expect(Email).toBeTruthy();
|
||||
expect(EmailManager).toBeTruthy();
|
||||
expect(StatsManager).toBeTruthy();
|
||||
expect(ConfigManager).toBeTruthy();
|
||||
expect(LogManager).toBeTruthy();
|
||||
expect(RadiusManager).toBeTruthy();
|
||||
expect(RadiusClientManager).toBeTruthy();
|
||||
expect(RadiusVlanManager).toBeTruthy();
|
||||
expect(RadiusSessionManager).toBeTruthy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -4,6 +4,7 @@ import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
@@ -15,6 +16,21 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
});
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
@@ -22,7 +38,8 @@ tap.test('should respond to health status request', async () => {
|
||||
);
|
||||
|
||||
const response = await healthRequest.fire({
|
||||
detailed: false
|
||||
identity: adminIdentity,
|
||||
detailed: false,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
@@ -37,7 +54,8 @@ tap.test('should respond to server statistics request', async () => {
|
||||
);
|
||||
|
||||
const response = await statsRequest.fire({
|
||||
includeHistory: false
|
||||
identity: adminIdentity,
|
||||
includeHistory: false,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('stats');
|
||||
@@ -52,13 +70,19 @@ tap.test('should respond to configuration request', async () => {
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
const response = await configRequest.fire({});
|
||||
const response = await configRequest.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('system');
|
||||
expect(response.config).toHaveProperty('smartProxy');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
expect(response.config).toHaveProperty('tls');
|
||||
expect(response.config).toHaveProperty('cache');
|
||||
expect(response.config).toHaveProperty('radius');
|
||||
expect(response.config).toHaveProperty('remoteIngress');
|
||||
});
|
||||
|
||||
tap.test('should handle log retrieval request', async () => {
|
||||
@@ -68,7 +92,8 @@ tap.test('should handle log retrieval request', async () => {
|
||||
);
|
||||
|
||||
const response = await logsRequest.fire({
|
||||
limit: 10
|
||||
identity: adminIdentity,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('logs');
|
||||
@@ -77,6 +102,20 @@ tap.test('should handle log retrieval request', async () => {
|
||||
expect(response.logs).toBeArray();
|
||||
});
|
||||
|
||||
tap.test('should reject unauthenticated requests', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
try {
|
||||
await healthRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
@@ -82,35 +82,42 @@ tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should allow access to public endpoints without auth', async () => {
|
||||
tap.test('should reject protected endpoints without auth', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
// No identity provided
|
||||
const response = await healthRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
console.log('Public endpoint accessible without auth');
|
||||
try {
|
||||
// No identity provided — should be rejected
|
||||
await healthRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Protected endpoint correctly rejects unauthenticated request');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should allow read-only config access', async () => {
|
||||
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
// Config is read-only and doesn't require auth
|
||||
const response = await configRequest.fire({});
|
||||
const response = await configRequest.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('system');
|
||||
expect(response.config).toHaveProperty('smartProxy');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
console.log('Configuration read successfully');
|
||||
expect(response.config).toHaveProperty('tls');
|
||||
expect(response.config).toHaveProperty('cache');
|
||||
expect(response.config).toHaveProperty('radius');
|
||||
expect(response.config).toHaveProperty('remoteIngress');
|
||||
console.log('Authenticated access to config successful');
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Configure services as needed for development
|
||||
// OpsServer always starts on port 3000
|
||||
|
||||
// Example: Add SmartProxy routes
|
||||
// smartProxyConfig: {
|
||||
// routes: [...]
|
||||
// },
|
||||
|
||||
// Example: Add email configuration
|
||||
// emailConfig: {
|
||||
// ports: [2525],
|
||||
// hostname: 'localhost',
|
||||
// domains: [],
|
||||
// routes: []
|
||||
// },
|
||||
// SmartProxy routes for development/demo
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-traffic',
|
||||
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||
},
|
||||
{
|
||||
name: 'api-gateway',
|
||||
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
|
||||
},
|
||||
{
|
||||
name: 'tls-passthrough',
|
||||
match: { ports: [18443], domains: ['secure.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 4443 }],
|
||||
tls: { mode: 'passthrough' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Disable cache/mongo for dev
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
console.log('Starting DcRouter in development mode...');
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '5.0.2',
|
||||
version: '11.2.9',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
5
ts/cache/classes.cachedb.ts
vendored
5
ts/cache/classes.cachedb.ts
vendored
@@ -1,11 +1,12 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { defaultTsmDbPath } from '../paths.js';
|
||||
|
||||
/**
|
||||
* Configuration options for CacheDb
|
||||
*/
|
||||
export interface ICacheDbOptions {
|
||||
/** Base storage path for TsmDB data (default: /etc/dcrouter/tsmdb) */
|
||||
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||
storagePath?: string;
|
||||
/** Database name (default: dcrouter) */
|
||||
dbName?: string;
|
||||
@@ -29,7 +30,7 @@ export class CacheDb {
|
||||
|
||||
constructor(options: ICacheDbOptions = {}) {
|
||||
this.options = {
|
||||
storagePath: options.storagePath || '/etc/dcrouter/tsmdb',
|
||||
storagePath: options.storagePath || defaultTsmDbPath,
|
||||
dbName: options.dbName || 'dcrouter',
|
||||
debug: options.debug || false,
|
||||
};
|
||||
|
||||
137
ts/classes.cert-provision-scheduler.ts
Normal file
137
ts/classes.cert-provision-scheduler.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { logger } from './logger.js';
|
||||
import type { StorageManager } from './storage/index.js';
|
||||
|
||||
interface IBackoffEntry {
|
||||
failures: number;
|
||||
lastFailure: string; // ISO string
|
||||
retryAfter: string; // ISO string
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages certificate provisioning scheduling with:
|
||||
* - Per-domain exponential backoff persisted in StorageManager
|
||||
*
|
||||
* Note: Serial stagger queue was removed — smartacme v9 handles
|
||||
* concurrency, per-domain dedup, and rate limiting internally.
|
||||
*/
|
||||
export class CertProvisionScheduler {
|
||||
private storageManager: StorageManager;
|
||||
private maxBackoffHours: number;
|
||||
|
||||
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||
private backoffCache = new Map<string, IBackoffEntry>();
|
||||
|
||||
constructor(
|
||||
storageManager: StorageManager,
|
||||
options?: { maxBackoffHours?: number }
|
||||
) {
|
||||
this.storageManager = storageManager;
|
||||
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a domain's backoff entry
|
||||
*/
|
||||
private backoffKey(domain: string): string {
|
||||
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return `/cert-backoff/${clean}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load backoff entry from storage (with in-memory cache)
|
||||
*/
|
||||
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
||||
const cached = this.backoffCache.get(domain);
|
||||
if (cached) return cached;
|
||||
|
||||
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
||||
if (entry) {
|
||||
this.backoffCache.set(domain, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save backoff entry to both cache and storage
|
||||
*/
|
||||
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||
this.backoffCache.set(domain, entry);
|
||||
await this.storageManager.setJSON(this.backoffKey(domain), entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is currently in backoff
|
||||
*/
|
||||
async isInBackoff(domain: string): Promise<boolean> {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
if (!entry) return false;
|
||||
|
||||
const retryAfter = new Date(entry.retryAfter);
|
||||
return retryAfter.getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a provisioning failure for a domain.
|
||||
* Sets exponential backoff: min(failures^2 * 1h, maxBackoffHours)
|
||||
*/
|
||||
async recordFailure(domain: string, error?: string): Promise<void> {
|
||||
const existing = await this.loadBackoff(domain);
|
||||
const failures = (existing?.failures ?? 0) + 1;
|
||||
|
||||
// Exponential backoff: failures^2 hours, capped
|
||||
const backoffHours = Math.min(failures * failures, this.maxBackoffHours);
|
||||
const retryAfter = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
|
||||
|
||||
const entry: IBackoffEntry = {
|
||||
failures,
|
||||
lastFailure: new Date().toISOString(),
|
||||
retryAfter: retryAfter.toISOString(),
|
||||
lastError: error,
|
||||
};
|
||||
|
||||
await this.saveBackoff(domain, entry);
|
||||
logger.log('warn', `Cert backoff for ${domain}: ${failures} failures, retry after ${retryAfter.toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear backoff for a domain (on success or manual override)
|
||||
*/
|
||||
async clearBackoff(domain: string): Promise<void> {
|
||||
this.backoffCache.delete(domain);
|
||||
try {
|
||||
await this.storageManager.delete(this.backoffKey(domain));
|
||||
} catch {
|
||||
// Ignore delete errors (key may not exist)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all in-memory backoff cache entries
|
||||
*/
|
||||
public clear(): void {
|
||||
this.backoffCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backoff info for UI display
|
||||
*/
|
||||
async getBackoffInfo(domain: string): Promise<{
|
||||
failures: number;
|
||||
retryAfter?: string;
|
||||
lastError?: string;
|
||||
} | null> {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
if (!entry) return null;
|
||||
|
||||
// Only return if still in backoff
|
||||
const retryAfter = new Date(entry.retryAfter);
|
||||
if (retryAfter.getTime() <= Date.now()) return null;
|
||||
|
||||
return {
|
||||
failures: entry.failures,
|
||||
retryAfter: entry.retryAfter,
|
||||
lastError: entry.lastError,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
46
ts/classes.storage-cert-manager.ts
Normal file
46
ts/classes.storage-cert-manager.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { StorageManager } from './storage/index.js';
|
||||
|
||||
/**
|
||||
* ICertManager implementation backed by StorageManager.
|
||||
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
||||
* survive process restarts without re-hitting ACME.
|
||||
*/
|
||||
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||
private keyPrefix = '/certs/';
|
||||
|
||||
constructor(private storageManager: StorageManager) {}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
||||
if (!data) return null;
|
||||
return new plugins.smartacme.Cert(data);
|
||||
}
|
||||
|
||||
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
||||
id: cert.id,
|
||||
domainName: cert.domainName,
|
||||
created: cert.created,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
csr: cert.csr,
|
||||
validUntil: cert.validUntil,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCertificate(domainName: string): Promise<void> {
|
||||
await this.storageManager.delete(this.keyPrefix + domainName);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async wipe(): Promise<void> {
|
||||
const keys = await this.storageManager.list(this.keyPrefix);
|
||||
for (const key of keys) {
|
||||
await this.storageManager.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
173
ts/config/classes.api-token-manager.ts
Normal file
173
ts/config/classes.api-token-manager.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import type {
|
||||
IStoredApiToken,
|
||||
IApiTokenInfo,
|
||||
TApiTokenScope,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const TOKENS_PREFIX = '/config-api/tokens/';
|
||||
const TOKEN_PREFIX_STR = 'dcr_';
|
||||
|
||||
export class ApiTokenManager {
|
||||
private tokens = new Map<string, IStoredApiToken>();
|
||||
|
||||
constructor(private storageManager: StorageManager) {}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadTokens();
|
||||
if (this.tokens.size > 0) {
|
||||
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token lifecycle
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a new API token. Returns the raw token value (shown once).
|
||||
*/
|
||||
public async createToken(
|
||||
name: string,
|
||||
scopes: TApiTokenScope[],
|
||||
expiresInDays: number | null,
|
||||
createdBy: string,
|
||||
): Promise<{ id: string; rawToken: string }> {
|
||||
const id = plugins.uuid.v4();
|
||||
const randomBytes = plugins.crypto.randomBytes(32);
|
||||
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||
|
||||
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
|
||||
const now = Date.now();
|
||||
const stored: IStoredApiToken = {
|
||||
id,
|
||||
name,
|
||||
tokenHash,
|
||||
scopes,
|
||||
createdAt: now,
|
||||
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||
lastUsedAt: null,
|
||||
createdBy,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
this.tokens.set(id, stored);
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${name}' created (id: ${id})`);
|
||||
return { id, rawToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw token string. Returns the stored token if valid, null otherwise.
|
||||
* Also updates lastUsedAt.
|
||||
*/
|
||||
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
|
||||
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
|
||||
|
||||
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
|
||||
for (const stored of this.tokens.values()) {
|
||||
if (stored.tokenHash === hash) {
|
||||
if (!stored.enabled) return null;
|
||||
if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null;
|
||||
|
||||
// Update lastUsedAt (fire and forget)
|
||||
stored.lastUsedAt = Date.now();
|
||||
this.persistToken(stored).catch(() => {});
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token has a specific scope.
|
||||
*/
|
||||
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
||||
return token.scopes.includes(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens (safe info only, no hashes).
|
||||
*/
|
||||
public listTokens(): IApiTokenInfo[] {
|
||||
const result: IApiTokenInfo[] = [];
|
||||
for (const stored of this.tokens.values()) {
|
||||
result.push({
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
scopes: stored.scopes,
|
||||
createdAt: stored.createdAt,
|
||||
expiresAt: stored.expiresAt,
|
||||
lastUsedAt: stored.lastUsedAt,
|
||||
enabled: stored.enabled,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token.
|
||||
*/
|
||||
public async revokeToken(id: string): Promise<boolean> {
|
||||
if (!this.tokens.has(id)) return false;
|
||||
const token = this.tokens.get(id)!;
|
||||
this.tokens.delete(id);
|
||||
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
|
||||
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll (regenerate) a token's secret while keeping its identity.
|
||||
* Returns the new raw token value (shown once).
|
||||
*/
|
||||
public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> {
|
||||
const stored = this.tokens.get(id);
|
||||
if (!stored) return null;
|
||||
|
||||
const randomBytes = plugins.crypto.randomBytes(32);
|
||||
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||
|
||||
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
|
||||
return { id, rawToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a token.
|
||||
*/
|
||||
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
|
||||
const stored = this.tokens.get(id);
|
||||
if (!stored) return false;
|
||||
stored.enabled = enabled;
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private
|
||||
// =========================================================================
|
||||
|
||||
private async loadTokens(): Promise<void> {
|
||||
const keys = await this.storageManager.list(TOKENS_PREFIX);
|
||||
for (const key of keys) {
|
||||
if (!key.endsWith('.json')) continue;
|
||||
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
|
||||
if (stored?.id) {
|
||||
this.tokens.set(stored.id, stored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
|
||||
}
|
||||
}
|
||||
271
ts/config/classes.route-config-manager.ts
Normal file
271
ts/config/classes.route-config-manager.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import type {
|
||||
IStoredRoute,
|
||||
IRouteOverride,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const ROUTES_PREFIX = '/config-api/routes/';
|
||||
const OVERRIDES_PREFIX = '/config-api/overrides/';
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
private overrides = new Map<string, IRouteOverride>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
|
||||
constructor(
|
||||
private storageManager: StorageManager,
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadStoredRoutes();
|
||||
await this.loadOverrides();
|
||||
this.computeWarnings();
|
||||
this.logWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Merged view
|
||||
// =========================================================================
|
||||
|
||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||
const merged: IMergedRoute[] = [];
|
||||
|
||||
// Hardcoded routes
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
merged.push({
|
||||
route,
|
||||
source: 'hardcoded',
|
||||
enabled: override ? override.enabled : true,
|
||||
overridden: !!override,
|
||||
});
|
||||
}
|
||||
|
||||
// Programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
merged.push({
|
||||
route: stored.route,
|
||||
source: 'programmatic',
|
||||
enabled: stored.enabled,
|
||||
overridden: false,
|
||||
storedRouteId: stored.id,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return { routes: merged, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Programmatic route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
// Ensure route has a name
|
||||
if (!route.name) {
|
||||
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
const stored: IStoredRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
};
|
||||
|
||||
this.storedRoutes.set(id, stored);
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateRoute(
|
||||
id: string,
|
||||
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
|
||||
): Promise<boolean> {
|
||||
const stored = this.storedRoutes.get(id);
|
||||
if (!stored) return false;
|
||||
|
||||
if (patch.route) {
|
||||
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
stored.enabled = patch.enabled;
|
||||
}
|
||||
stored.updatedAt = Date.now();
|
||||
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async deleteRoute(id: string): Promise<boolean> {
|
||||
if (!this.storedRoutes.has(id)) return false;
|
||||
this.storedRoutes.delete(id);
|
||||
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
|
||||
return this.updateRoute(id, { enabled });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hardcoded route overrides
|
||||
// =========================================================================
|
||||
|
||||
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
||||
const override: IRouteOverride = {
|
||||
routeName,
|
||||
enabled,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy,
|
||||
};
|
||||
this.overrides.set(routeName, override);
|
||||
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
public async removeOverride(routeName: string): Promise<boolean> {
|
||||
if (!this.overrides.has(routeName)) return false;
|
||||
this.overrides.delete(routeName);
|
||||
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadStoredRoutes(): Promise<void> {
|
||||
const keys = await this.storageManager.list(ROUTES_PREFIX);
|
||||
for (const key of keys) {
|
||||
if (!key.endsWith('.json')) continue;
|
||||
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
|
||||
if (stored?.id) {
|
||||
this.storedRoutes.set(stored.id, stored);
|
||||
}
|
||||
}
|
||||
if (this.storedRoutes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOverrides(): Promise<void> {
|
||||
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
|
||||
for (const key of keys) {
|
||||
if (!key.endsWith('.json')) continue;
|
||||
const override = await this.storageManager.getJSON<IRouteOverride>(key);
|
||||
if (override?.routeName) {
|
||||
this.overrides.set(override.routeName, override);
|
||||
}
|
||||
}
|
||||
if (this.overrides.size > 0) {
|
||||
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: warnings
|
||||
// =========================================================================
|
||||
|
||||
private computeWarnings(): void {
|
||||
this.warnings = [];
|
||||
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||
|
||||
// Check overrides
|
||||
for (const [routeName, override] of this.overrides) {
|
||||
if (!hardcodedNames.has(routeName)) {
|
||||
this.warnings.push({
|
||||
type: 'orphaned-override',
|
||||
routeName,
|
||||
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
||||
});
|
||||
} else if (!override.enabled) {
|
||||
this.warnings.push({
|
||||
type: 'disabled-hardcoded',
|
||||
routeName,
|
||||
message: `Route '${routeName}' is disabled via API override`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check disabled programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (!stored.enabled) {
|
||||
const name = stored.route.name || stored.id;
|
||||
this.warnings.push({
|
||||
type: 'disabled-programmatic',
|
||||
routeName: name,
|
||||
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logWarnings(): void {
|
||||
for (const w of this.warnings) {
|
||||
logger.log('warn', w.message);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: apply merged routes to SmartProxy
|
||||
// =========================================================================
|
||||
|
||||
private async applyRoutes(): Promise<void> {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Add enabled hardcoded routes (respecting overrides)
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
if (override && !override.enabled) {
|
||||
continue; // Skip disabled hardcoded route
|
||||
}
|
||||
enabledRoutes.push(route);
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
enabledRoutes.push(stored.route);
|
||||
}
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// Export validation tools only
|
||||
export * from './validator.js';
|
||||
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||
@@ -10,4 +10,7 @@ export * from './classes.dcrouter.js';
|
||||
// RADIUS module
|
||||
export * from './radius/index.js';
|
||||
|
||||
// Remote Ingress module
|
||||
export * from './remoteingress/index.js';
|
||||
|
||||
export const runCli = async () => {};
|
||||
|
||||
11
ts/logger.ts
11
ts/logger.ts
@@ -1,5 +1,6 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer';
|
||||
|
||||
// Map NODE_ENV to valid TEnvironment
|
||||
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||
@@ -10,8 +11,11 @@ const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
|
||||
'production': 'production'
|
||||
};
|
||||
|
||||
// Default Smartlog instance
|
||||
const baseLogger = new plugins.smartlog.Smartlog({
|
||||
// In-memory log buffer for the OpsServer UI
|
||||
export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 });
|
||||
|
||||
// Default Smartlog instance (exported so OpsServer can add push destinations)
|
||||
export const baseLogger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: envMap[nodeEnv] || 'production',
|
||||
runtime: 'node',
|
||||
@@ -19,6 +23,9 @@ const baseLogger = new plugins.smartlog.Smartlog({
|
||||
}
|
||||
});
|
||||
|
||||
// Wire the buffer destination so all logs are captured
|
||||
baseLogger.addLogDestination(logBuffer);
|
||||
|
||||
// Extended logger compatible with the original enhanced logger API
|
||||
class StandardLogger {
|
||||
private defaultContext: Record<string, any> = {};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { DcRouter } from '../classes.dcrouter.js';
|
||||
import { MetricsCache } from './classes.metricscache.js';
|
||||
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export class MetricsManager {
|
||||
private logger: plugins.smartlog.Smartlog;
|
||||
private metricsLogger: plugins.smartlog.Smartlog;
|
||||
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
||||
private dcRouter: DcRouter;
|
||||
private resetInterval?: NodeJS.Timeout;
|
||||
@@ -33,10 +35,17 @@ export class MetricsManager {
|
||||
queryTypes: {} as Record<string, number>,
|
||||
topDomains: new Map<string, number>(),
|
||||
lastResetDate: new Date().toDateString(),
|
||||
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
|
||||
// Per-second query count ring buffer (300 entries = 5 minutes)
|
||||
queryRing: new Int32Array(300),
|
||||
queryRingLastSecond: 0, // last epoch second that was written
|
||||
responseTimes: [] as number[], // Track response times in ms
|
||||
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
||||
};
|
||||
|
||||
// Per-minute time-series buckets for charts
|
||||
private emailMinuteBuckets = new Map<number, { sent: number; received: number; failed: number }>();
|
||||
private dnsMinuteBuckets = new Map<number, { queries: number }>();
|
||||
|
||||
// Track security-specific metrics
|
||||
private securityMetrics = {
|
||||
blockedIPs: 0,
|
||||
@@ -50,15 +59,15 @@ export class MetricsManager {
|
||||
|
||||
constructor(dcRouter: DcRouter) {
|
||||
this.dcRouter = dcRouter;
|
||||
// Create a new Smartlog instance for metrics
|
||||
this.logger = new plugins.smartlog.Smartlog({
|
||||
// Create a Smartlog instance for SmartMetrics (requires its own instance)
|
||||
this.metricsLogger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: 'production',
|
||||
runtime: 'node',
|
||||
zone: 'dcrouter-metrics',
|
||||
}
|
||||
});
|
||||
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.logger, 'dcrouter');
|
||||
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter');
|
||||
// Initialize metrics cache with 500ms TTL
|
||||
this.metricsCache = new MetricsCache(500);
|
||||
}
|
||||
@@ -88,8 +97,10 @@ export class MetricsManager {
|
||||
this.dnsMetrics.cacheMisses = 0;
|
||||
this.dnsMetrics.queryTypes = {};
|
||||
this.dnsMetrics.topDomains.clear();
|
||||
this.dnsMetrics.queryTimestamps = [];
|
||||
this.dnsMetrics.queryRing.fill(0);
|
||||
this.dnsMetrics.queryRingLastSecond = 0;
|
||||
this.dnsMetrics.responseTimes = [];
|
||||
this.dnsMetrics.recentQueries = [];
|
||||
this.dnsMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
@@ -102,9 +113,12 @@ export class MetricsManager {
|
||||
this.securityMetrics.incidents = [];
|
||||
this.securityMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
// Prune old time-series buckets every minute (don't wait for lazy query)
|
||||
this.pruneOldBuckets();
|
||||
}, 60000); // Check every minute
|
||||
|
||||
this.logger.log('info', 'MetricsManager started');
|
||||
logger.log('info', 'MetricsManager started');
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
@@ -115,7 +129,13 @@ export class MetricsManager {
|
||||
}
|
||||
|
||||
this.smartMetrics.stop();
|
||||
this.logger.log('info', 'MetricsManager stopped');
|
||||
|
||||
// Clear caches and time-series buckets on shutdown
|
||||
this.metricsCache.clear();
|
||||
this.emailMinuteBuckets.clear();
|
||||
this.dnsMinuteBuckets.clear();
|
||||
|
||||
logger.log('info', 'MetricsManager stopped');
|
||||
}
|
||||
|
||||
// Get server metrics from SmartMetrics and SmartProxy
|
||||
@@ -124,31 +144,33 @@ export class MetricsManager {
|
||||
const smartMetricsData = await this.smartMetrics.getMetrics();
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
||||
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
|
||||
|
||||
return {
|
||||
uptime: process.uptime(),
|
||||
startTime: Date.now() - (process.uptime() * 1000),
|
||||
memoryUsage: {
|
||||
heapUsed: process.memoryUsage().heapUsed,
|
||||
heapTotal: process.memoryUsage().heapTotal,
|
||||
external: process.memoryUsage().external,
|
||||
rss: process.memoryUsage().rss,
|
||||
// Add SmartMetrics memory data
|
||||
heapUsed,
|
||||
heapTotal,
|
||||
external,
|
||||
rss,
|
||||
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||
},
|
||||
cpuUsage: {
|
||||
user: parseFloat(smartMetricsData.cpuUsageText || '0'),
|
||||
system: 0, // SmartMetrics doesn't separate user/system
|
||||
user: smartMetricsData.cpuPercentage,
|
||||
system: 0,
|
||||
},
|
||||
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
|
||||
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
|
||||
requestsPerSecond: proxyMetrics ? proxyMetrics.requests.perSecond() : 0,
|
||||
throughput: proxyMetrics ? {
|
||||
bytesIn: proxyMetrics.totals.bytesIn(),
|
||||
bytesOut: proxyMetrics.totals.bytesOut()
|
||||
} : { bytesIn: 0, bytesOut: 0 },
|
||||
bytesOut: proxyMetrics.totals.bytesOut(),
|
||||
bytesInPerSecond: proxyMetrics.throughput.instant().in,
|
||||
bytesOutPerSecond: proxyMetrics.throughput.instant().out,
|
||||
} : { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -200,11 +222,8 @@ export class MetricsManager {
|
||||
.slice(0, 10)
|
||||
.map(([domain, count]) => ({ domain, count }));
|
||||
|
||||
// Calculate queries per second from recent timestamps
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60000;
|
||||
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
|
||||
const queriesPerSecond = recentQueries.length / 60;
|
||||
// Calculate queries per second from ring buffer (sum last 60 seconds)
|
||||
const queriesPerSecond = this.getQueryRingSum(60) / 60;
|
||||
|
||||
// Calculate average response time
|
||||
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
||||
@@ -221,13 +240,39 @@ export class MetricsManager {
|
||||
queryTypes: this.dnsMetrics.queryTypes,
|
||||
averageResponseTime: Math.round(avgResponseTime),
|
||||
activeDomains: this.dnsMetrics.topDomains.size,
|
||||
recentQueries: this.dnsMetrics.recentQueries.slice(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync security metrics from the SecurityLogger singleton (last 24h).
|
||||
* Called before returning security stats so counters reflect real events.
|
||||
*/
|
||||
private syncFromSecurityLogger(): void {
|
||||
try {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
const summary = securityLogger.getEventsSummary(86400000); // last 24h
|
||||
|
||||
this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0;
|
||||
this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0;
|
||||
this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC
|
||||
this.securityMetrics.authFailures =
|
||||
summary.byType[SecurityEventType.AUTHENTICATION] || 0;
|
||||
this.securityMetrics.blockedIPs =
|
||||
(summary.byType[SecurityEventType.IP_REPUTATION] || 0) +
|
||||
(summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0);
|
||||
} catch {
|
||||
// SecurityLogger may not be initialized yet — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Get security metrics
|
||||
public async getSecurityStats() {
|
||||
return this.metricsCache.get('securityStats', () => {
|
||||
// Sync counters from the real SecurityLogger events
|
||||
this.syncFromSecurityLogger();
|
||||
|
||||
// Get recent incidents (last 20)
|
||||
const recentIncidents = this.securityMetrics.incidents.slice(-20);
|
||||
|
||||
@@ -273,10 +318,19 @@ export class MetricsManager {
|
||||
// Email event tracking methods
|
||||
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
|
||||
this.emailMetrics.sentToday++;
|
||||
this.incrementEmailBucket('sent');
|
||||
|
||||
if (recipient) {
|
||||
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
||||
this.emailMetrics.recipients.set(recipient, count + 1);
|
||||
|
||||
// Cap recipients map to prevent unbounded growth within a day
|
||||
if (this.emailMetrics.recipients.size > this.MAX_TOP_DOMAINS) {
|
||||
const sorted = Array.from(this.emailMetrics.recipients.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8));
|
||||
this.emailMetrics.recipients = new Map(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveryTimeMs) {
|
||||
@@ -301,6 +355,7 @@ export class MetricsManager {
|
||||
|
||||
public trackEmailReceived(sender?: string): void {
|
||||
this.emailMetrics.receivedToday++;
|
||||
this.incrementEmailBucket('received');
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
@@ -316,6 +371,7 @@ export class MetricsManager {
|
||||
|
||||
public trackEmailFailed(recipient?: string, reason?: string): void {
|
||||
this.emailMetrics.failedToday++;
|
||||
this.incrementEmailBucket('failed');
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
@@ -349,8 +405,21 @@ export class MetricsManager {
|
||||
}
|
||||
|
||||
// DNS event tracking methods
|
||||
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void {
|
||||
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void {
|
||||
this.dnsMetrics.totalQueries++;
|
||||
this.incrementDnsBucket();
|
||||
|
||||
// Store recent query entry
|
||||
this.dnsMetrics.recentQueries.push({
|
||||
timestamp: Date.now(),
|
||||
domain,
|
||||
type: queryType,
|
||||
answered: answered ?? true,
|
||||
responseTimeMs: responseTimeMs ?? 0,
|
||||
});
|
||||
if (this.dnsMetrics.recentQueries.length > 100) {
|
||||
this.dnsMetrics.recentQueries.shift();
|
||||
}
|
||||
|
||||
if (cacheHit) {
|
||||
this.dnsMetrics.cacheHits++;
|
||||
@@ -358,12 +427,8 @@ export class MetricsManager {
|
||||
this.dnsMetrics.cacheMisses++;
|
||||
}
|
||||
|
||||
// Track query timestamp
|
||||
this.dnsMetrics.queryTimestamps.push(Date.now());
|
||||
|
||||
// Keep only timestamps from last 5 minutes
|
||||
const fiveMinutesAgo = Date.now() - 300000;
|
||||
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
|
||||
// Increment per-second query counter in ring buffer
|
||||
this.incrementQueryRing();
|
||||
|
||||
// Track response time if provided
|
||||
if (responseTimeMs) {
|
||||
@@ -487,8 +552,12 @@ export class MetricsManager {
|
||||
return {
|
||||
connectionsByIP: new Map<string, number>(),
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
topIPs: [] as Array<{ ip: string; count: number }>,
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -511,12 +580,169 @@ export class MetricsManager {
|
||||
bytesOut: proxyMetrics.totals.bytesOut()
|
||||
};
|
||||
|
||||
// Get throughput history from Rust engine (up to 300 seconds)
|
||||
const throughputHistory = proxyMetrics.throughput.history(300);
|
||||
|
||||
// Get per-IP throughput
|
||||
const throughputByIP = proxyMetrics.throughput.byIP();
|
||||
|
||||
// Get HTTP request rates
|
||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||
const requestsTotal = proxyMetrics.requests.total();
|
||||
|
||||
return {
|
||||
connectionsByIP,
|
||||
throughputRate,
|
||||
topIPs,
|
||||
totalDataTransferred,
|
||||
throughputHistory,
|
||||
throughputByIP,
|
||||
requestsPerSecond,
|
||||
requestsTotal,
|
||||
};
|
||||
}, 200); // Use 200ms cache for more frequent updates
|
||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||
}
|
||||
|
||||
// --- Time-series helpers ---
|
||||
|
||||
private static minuteKey(ts: number = Date.now()): number {
|
||||
return Math.floor(ts / 60000) * 60000;
|
||||
}
|
||||
|
||||
private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void {
|
||||
const key = MetricsManager.minuteKey();
|
||||
let bucket = this.emailMinuteBuckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { sent: 0, received: 0, failed: 0 };
|
||||
this.emailMinuteBuckets.set(key, bucket);
|
||||
}
|
||||
bucket[field]++;
|
||||
}
|
||||
|
||||
private incrementDnsBucket(): void {
|
||||
const key = MetricsManager.minuteKey();
|
||||
let bucket = this.dnsMinuteBuckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { queries: 0 };
|
||||
this.dnsMinuteBuckets.set(key, bucket);
|
||||
}
|
||||
bucket.queries++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the per-second query counter in the ring buffer.
|
||||
* Zeros any stale slots between the last write and the current second.
|
||||
*/
|
||||
private incrementQueryRing(): void {
|
||||
const currentSecond = Math.floor(Date.now() / 1000);
|
||||
const ring = this.dnsMetrics.queryRing;
|
||||
const last = this.dnsMetrics.queryRingLastSecond;
|
||||
|
||||
if (last === 0) {
|
||||
// First call — zero and anchor
|
||||
ring.fill(0);
|
||||
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||
ring[currentSecond % ring.length] = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = currentSecond - last;
|
||||
if (gap >= ring.length) {
|
||||
// Entire ring is stale — clear all
|
||||
ring.fill(0);
|
||||
} else if (gap > 0) {
|
||||
// Zero slots from (last+1) to currentSecond (inclusive)
|
||||
for (let s = last + 1; s <= currentSecond; s++) {
|
||||
ring[s % ring.length] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||
ring[currentSecond % ring.length]++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum query counts from the ring buffer for the last N seconds.
|
||||
*/
|
||||
private getQueryRingSum(seconds: number): number {
|
||||
const currentSecond = Math.floor(Date.now() / 1000);
|
||||
const ring = this.dnsMetrics.queryRing;
|
||||
const last = this.dnsMetrics.queryRingLastSecond;
|
||||
|
||||
if (last === 0) return 0;
|
||||
|
||||
// First, zero stale slots so reads are accurate even without writes
|
||||
const gap = currentSecond - last;
|
||||
if (gap >= ring.length) return 0; // all data is stale
|
||||
|
||||
let sum = 0;
|
||||
const limit = Math.min(seconds, ring.length);
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const sec = currentSecond - i;
|
||||
if (sec < last - (ring.length - 1)) break; // slot is from older cycle
|
||||
if (sec > last) continue; // no writes yet for this second
|
||||
sum += ring[sec % ring.length];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
private pruneOldBuckets(): void {
|
||||
const cutoff = Date.now() - 86400000; // 24h
|
||||
for (const key of this.emailMinuteBuckets.keys()) {
|
||||
if (key < cutoff) this.emailMinuteBuckets.delete(key);
|
||||
}
|
||||
for (const key of this.dnsMinuteBuckets.keys()) {
|
||||
if (key < cutoff) this.dnsMinuteBuckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email time-series data for the last N hours, aggregated per minute.
|
||||
*/
|
||||
public getEmailTimeSeries(hours: number = 24): {
|
||||
sent: Array<{ timestamp: number; value: number }>;
|
||||
received: Array<{ timestamp: number; value: number }>;
|
||||
failed: Array<{ timestamp: number; value: number }>;
|
||||
} {
|
||||
this.pruneOldBuckets();
|
||||
const cutoff = Date.now() - hours * 3600000;
|
||||
const sent: Array<{ timestamp: number; value: number }> = [];
|
||||
const received: Array<{ timestamp: number; value: number }> = [];
|
||||
const failed: Array<{ timestamp: number; value: number }> = [];
|
||||
|
||||
const sortedKeys = Array.from(this.emailMinuteBuckets.keys())
|
||||
.filter((k) => k >= cutoff)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const bucket = this.emailMinuteBuckets.get(key)!;
|
||||
sent.push({ timestamp: key, value: bucket.sent });
|
||||
received.push({ timestamp: key, value: bucket.received });
|
||||
failed.push({ timestamp: key, value: bucket.failed });
|
||||
}
|
||||
|
||||
return { sent, received, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DNS time-series data for the last N hours, aggregated per minute.
|
||||
*/
|
||||
public getDnsTimeSeries(hours: number = 24): {
|
||||
queries: Array<{ timestamp: number; value: number }>;
|
||||
} {
|
||||
this.pruneOldBuckets();
|
||||
const cutoff = Date.now() - hours * 3600000;
|
||||
const queries: Array<{ timestamp: number; value: number }> = [];
|
||||
|
||||
const sortedKeys = Array.from(this.dnsMinuteBuckets.keys())
|
||||
.filter((k) => k >= cutoff)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const bucket = this.dnsMinuteBuckets.get(key)!;
|
||||
queries.push({ timestamp: key, value: bucket.queries });
|
||||
}
|
||||
|
||||
return { queries };
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,20 @@ import type DcRouter from '../classes.dcrouter.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import * as handlers from './handlers/index.js';
|
||||
import * as interfaces from '../../ts_interfaces/index.js';
|
||||
import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js';
|
||||
|
||||
export class OpsServer {
|
||||
public dcRouterRef: DcRouter;
|
||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||
|
||||
// TypedRouter for OpsServer-specific handlers
|
||||
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Auth-enforced routers — middleware validates identity before any handler runs
|
||||
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||
|
||||
// Handler instances
|
||||
public adminHandler: handlers.AdminHandler;
|
||||
private configHandler: handlers.ConfigHandler;
|
||||
@@ -18,6 +24,10 @@ export class OpsServer {
|
||||
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;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -47,21 +57,44 @@ export class OpsServer {
|
||||
* Set up all TypedRequest handlers
|
||||
*/
|
||||
private async setupHandlers(): Promise<void> {
|
||||
// Instantiate all handlers - they self-register with the typedrouter
|
||||
// AdminHandler must be initialized first (JWT setup needed for guards)
|
||||
this.adminHandler = new handlers.AdminHandler(this);
|
||||
await this.adminHandler.initialize(); // JWT needs async initialization
|
||||
await this.adminHandler.initialize();
|
||||
|
||||
// viewRouter middleware: requires valid identity (any logged-in user)
|
||||
this.viewRouter.addMiddleware(async (typedRequest) => {
|
||||
await requireValidIdentity(this.adminHandler, typedRequest.request);
|
||||
});
|
||||
|
||||
// adminRouter middleware: requires admin identity
|
||||
this.adminRouter.addMiddleware(async (typedRequest) => {
|
||||
await requireAdminIdentity(this.adminHandler, typedRequest.request);
|
||||
});
|
||||
|
||||
// Connect auth routers to the main typedrouter
|
||||
this.typedrouter.addTypedRouter(this.viewRouter);
|
||||
this.typedrouter.addTypedRouter(this.adminRouter);
|
||||
|
||||
// Instantiate all handlers — they self-register with the appropriate router
|
||||
this.configHandler = new handlers.ConfigHandler(this);
|
||||
this.logsHandler = new handlers.LogsHandler(this);
|
||||
this.securityHandler = new handlers.SecurityHandler(this);
|
||||
this.statsHandler = new handlers.StatsHandler(this);
|
||||
this.radiusHandler = new handlers.RadiusHandler(this);
|
||||
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||
this.certificateHandler = new handlers.CertificateHandler(this);
|
||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
// Clean up log handler streams and push destination before stopping the server
|
||||
if (this.logsHandler) {
|
||||
this.logsHandler.cleanup();
|
||||
}
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
}
|
||||
|
||||
97
ts/opsserver/handlers/api-token.handler.ts
Normal file
97
ts/opsserver/handlers/api-token.handler.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class ApiTokenHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// All token management endpoints register directly on adminRouter
|
||||
// (middleware enforces admin JWT check, so no per-handler requireAdmin needed)
|
||||
const router = this.opsServerRef.adminRouter;
|
||||
|
||||
// Create API token
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
|
||||
'createApiToken',
|
||||
async (dataArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
}
|
||||
const result = await manager.createToken(
|
||||
dataArg.name,
|
||||
dataArg.scopes,
|
||||
dataArg.expiresInDays ?? null,
|
||||
dataArg.identity.userId,
|
||||
);
|
||||
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// List API tokens
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
|
||||
'listApiTokens',
|
||||
async (dataArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { tokens: [] };
|
||||
}
|
||||
return { tokens: manager.listTokens() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Revoke API token
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
|
||||
'revokeApiToken',
|
||||
async (dataArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
}
|
||||
const ok = await manager.revokeToken(dataArg.id);
|
||||
return { success: ok, message: ok ? undefined : 'Token not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Roll API token
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
|
||||
'rollApiToken',
|
||||
async (dataArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
}
|
||||
const result = await manager.rollToken(dataArg.id);
|
||||
if (!result) {
|
||||
return { success: false, message: 'Token not found' };
|
||||
}
|
||||
return { success: true, tokenValue: result.rawToken };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Toggle API token
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
|
||||
'toggleApiToken',
|
||||
async (dataArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
}
|
||||
const ok = await manager.toggleToken(dataArg.id, dataArg.enabled);
|
||||
return { success: ok, message: ok ? undefined : 'Token not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
511
ts/opsserver/handlers/certificate.handler.ts
Normal file
511
ts/opsserver/handlers/certificate.handler.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class CertificateHandler {
|
||||
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 Certificate Overview
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
||||
'getCertificateOverview',
|
||||
async (dataArg) => {
|
||||
const certificates = await this.buildCertificateOverview();
|
||||
const summary = this.buildSummary(certificates);
|
||||
return { certificates, summary };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||
|
||||
// Legacy route-based reprovision (backward compat)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||
'reprovisionCertificate',
|
||||
async (dataArg) => {
|
||||
return this.reprovisionCertificateByRoute(dataArg.routeName);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Domain-based reprovision (preferred)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
'reprovisionCertificateDomain',
|
||||
async (dataArg) => {
|
||||
return this.reprovisionCertificateDomain(dataArg.domain);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Delete certificate
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||
'deleteCertificate',
|
||||
async (dataArg) => {
|
||||
return this.deleteCertificate(dataArg.domain);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Export certificate
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||
'exportCertificate',
|
||||
async (dataArg) => {
|
||||
return this.exportCertificate(dataArg.domain);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Import certificate
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||
'importCertificate',
|
||||
async (dataArg) => {
|
||||
return this.importCertificate(dataArg.cert);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build domain-centric certificate overview.
|
||||
* Instead of one row per route, we produce one row per unique domain.
|
||||
*/
|
||||
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const smartProxy = dcRouter.smartProxy;
|
||||
if (!smartProxy) return [];
|
||||
|
||||
const routes = smartProxy.routeManager.getRoutes();
|
||||
|
||||
// Phase 1: Collect unique domains with their associated route info
|
||||
const domainMap = new Map<string, {
|
||||
routeNames: string[];
|
||||
source: interfaces.requests.TCertificateSource;
|
||||
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||
canReprovision: boolean;
|
||||
}>();
|
||||
|
||||
for (const route of routes) {
|
||||
if (!route.name) continue;
|
||||
|
||||
const tls = route.action?.tls;
|
||||
if (!tls) continue;
|
||||
|
||||
// Skip passthrough routes - they don't manage certificates
|
||||
if (tls.mode === 'passthrough') continue;
|
||||
|
||||
const routeDomains = route.match.domains
|
||||
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
|
||||
: [];
|
||||
|
||||
// Determine source
|
||||
let source: interfaces.requests.TCertificateSource = 'none';
|
||||
if (tls.certificate === 'auto') {
|
||||
if ((smartProxy.settings as any).certProvisionFunction) {
|
||||
source = 'provision-function';
|
||||
} else {
|
||||
source = 'acme';
|
||||
}
|
||||
} else if (tls.certificate && typeof tls.certificate === 'object') {
|
||||
source = 'static';
|
||||
}
|
||||
|
||||
const canReprovision = source === 'acme' || source === 'provision-function';
|
||||
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||
|
||||
for (const domain of routeDomains) {
|
||||
const existing = domainMap.get(domain);
|
||||
if (existing) {
|
||||
// Add this route name to the existing domain entry
|
||||
if (!existing.routeNames.includes(route.name)) {
|
||||
existing.routeNames.push(route.name);
|
||||
}
|
||||
// Upgrade source if more specific
|
||||
if (existing.source === 'none' && source !== 'none') {
|
||||
existing.source = source;
|
||||
existing.canReprovision = canReprovision;
|
||||
}
|
||||
} else {
|
||||
domainMap.set(domain, {
|
||||
routeNames: [route.name],
|
||||
source,
|
||||
tlsMode,
|
||||
canReprovision,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Resolve status for each unique domain
|
||||
const certificates: interfaces.requests.ICertificateInfo[] = [];
|
||||
|
||||
for (const [domain, info] of domainMap) {
|
||||
let status: interfaces.requests.TCertificateStatus = 'unknown';
|
||||
let expiryDate: string | undefined;
|
||||
let issuedAt: string | undefined;
|
||||
let issuer: string | undefined;
|
||||
let error: string | undefined;
|
||||
|
||||
// Check event-based status from certificateStatusMap (now keyed by domain)
|
||||
const eventStatus = dcRouter.certificateStatusMap.get(domain);
|
||||
if (eventStatus) {
|
||||
status = eventStatus.status;
|
||||
expiryDate = eventStatus.expiryDate;
|
||||
issuedAt = eventStatus.issuedAt;
|
||||
error = eventStatus.error;
|
||||
if (eventStatus.source) {
|
||||
issuer = eventStatus.source;
|
||||
}
|
||||
}
|
||||
|
||||
// Try SmartProxy certificate status if no event data
|
||||
if (status === 'unknown' && info.routeNames.length > 0) {
|
||||
try {
|
||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||
if (rustStatus) {
|
||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
||||
status = rustStatus.status;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Rust bridge may not support this command yet — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Check persisted cert data from StorageManager
|
||||
if (status === 'unknown') {
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
||||
if (!certData) {
|
||||
// Also check certStore path (proxy-certs)
|
||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
||||
}
|
||||
if (certData?.validUntil) {
|
||||
expiryDate = new Date(certData.validUntil).toISOString();
|
||||
if (certData.created) {
|
||||
issuedAt = new Date(certData.created).toISOString();
|
||||
}
|
||||
issuer = 'smartacme-dns-01';
|
||||
} else if (certData?.publicKey) {
|
||||
// certStore has the cert — parse PEM for expiry
|
||||
try {
|
||||
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
|
||||
expiryDate = new Date(x509.validTo).toISOString();
|
||||
issuedAt = new Date(x509.validFrom).toISOString();
|
||||
} catch { /* PEM parsing failed */ }
|
||||
status = 'valid';
|
||||
issuer = 'cert-store';
|
||||
} else if (certData) {
|
||||
status = 'valid';
|
||||
issuer = 'cert-store';
|
||||
}
|
||||
}
|
||||
|
||||
// Compute status from expiry date
|
||||
if (expiryDate && (status === 'valid' || status === 'unknown')) {
|
||||
const expiry = new Date(expiryDate);
|
||||
const now = new Date();
|
||||
const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysUntilExpiry < 0) {
|
||||
status = 'expired';
|
||||
} else if (daysUntilExpiry < 30) {
|
||||
status = 'expiring';
|
||||
} else {
|
||||
status = 'valid';
|
||||
}
|
||||
}
|
||||
|
||||
// Static certs with no other info default to 'valid'
|
||||
if (info.source === 'static' && status === 'unknown') {
|
||||
status = 'valid';
|
||||
}
|
||||
|
||||
// ACME/provision-function routes with no cert data are still provisioning
|
||||
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
|
||||
status = 'provisioning';
|
||||
}
|
||||
|
||||
// Phase 3: Attach backoff info
|
||||
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
|
||||
if (dcRouter.certProvisionScheduler) {
|
||||
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
|
||||
if (bi) {
|
||||
backoffInfo = bi;
|
||||
}
|
||||
}
|
||||
|
||||
certificates.push({
|
||||
domain,
|
||||
routeNames: info.routeNames,
|
||||
status,
|
||||
source: info.source,
|
||||
tlsMode: info.tlsMode,
|
||||
expiryDate,
|
||||
issuer,
|
||||
issuedAt,
|
||||
error,
|
||||
canReprovision: info.canReprovision,
|
||||
backoffInfo,
|
||||
});
|
||||
}
|
||||
|
||||
return certificates;
|
||||
}
|
||||
|
||||
private buildSummary(certificates: interfaces.requests.ICertificateInfo[]): {
|
||||
total: number;
|
||||
valid: number;
|
||||
expiring: number;
|
||||
expired: number;
|
||||
failed: number;
|
||||
unknown: number;
|
||||
} {
|
||||
const summary = { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 };
|
||||
summary.total = certificates.length;
|
||||
for (const cert of certificates) {
|
||||
switch (cert.status) {
|
||||
case 'valid': summary.valid++; break;
|
||||
case 'expiring': summary.expiring++; break;
|
||||
case 'expired': summary.expired++; break;
|
||||
case 'failed': summary.failed++; break;
|
||||
case 'provisioning': // count as unknown
|
||||
case 'unknown': summary.unknown++; break;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy route-based reprovisioning
|
||||
*/
|
||||
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const smartProxy = dcRouter.smartProxy;
|
||||
|
||||
if (!smartProxy) {
|
||||
return { success: false, message: 'SmartProxy is not running' };
|
||||
}
|
||||
|
||||
try {
|
||||
await smartProxy.provisionCertificate(routeName);
|
||||
// Clear event-based status for domains in this route
|
||||
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
||||
if (entry.routeNames.includes(routeName)) {
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
}
|
||||
}
|
||||
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
||||
*/
|
||||
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const smartProxy = dcRouter.smartProxy;
|
||||
|
||||
if (!smartProxy) {
|
||||
return { success: false, message: 'SmartProxy is not running' };
|
||||
}
|
||||
|
||||
// Clear backoff for this domain (user override)
|
||||
if (dcRouter.certProvisionScheduler) {
|
||||
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||
}
|
||||
|
||||
// Clear status map entry so it gets refreshed
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
|
||||
// Try to provision via SmartAcme directly
|
||||
if (dcRouter.smartAcme) {
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try provisioning via the first matching route
|
||||
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
||||
if (routeNames.length > 0) {
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete certificate data for a domain from storage
|
||||
*/
|
||||
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
|
||||
// Delete from all known storage paths
|
||||
const paths = [
|
||||
`/proxy-certs/${domain}`,
|
||||
`/proxy-certs/${cleanDomain}`,
|
||||
`/certs/${cleanDomain}`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
await dcRouter.storageManager.delete(path);
|
||||
} catch {
|
||||
// Path may not exist — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Clear from in-memory status map
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
|
||||
// Clear backoff info
|
||||
if (dcRouter.certProvisionScheduler) {
|
||||
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||
}
|
||||
|
||||
return { success: true, message: `Certificate data deleted for '${domain}'` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export certificate data for a domain as ICert-shaped JSON
|
||||
*/
|
||||
private async exportCertificate(domain: string): Promise<{
|
||||
success: boolean;
|
||||
cert?: {
|
||||
id: string;
|
||||
domainName: string;
|
||||
created: number;
|
||||
validUntil: number;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
csr: string;
|
||||
};
|
||||
message?: string;
|
||||
}> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
|
||||
// Try SmartAcme /certs/ path first (has full ICert fields)
|
||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
||||
if (certData && certData.publicKey && certData.privateKey) {
|
||||
return {
|
||||
success: true,
|
||||
cert: {
|
||||
id: certData.id || plugins.crypto.randomUUID(),
|
||||
domainName: certData.domainName || domain,
|
||||
created: certData.created || Date.now(),
|
||||
validUntil: certData.validUntil || 0,
|
||||
privateKey: certData.privateKey,
|
||||
publicKey: certData.publicKey,
|
||||
csr: certData.csr || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: try /proxy-certs/ with original domain
|
||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
||||
if (!certData || !certData.publicKey) {
|
||||
// Try with clean domain
|
||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
|
||||
}
|
||||
|
||||
if (certData && certData.publicKey && certData.privateKey) {
|
||||
return {
|
||||
success: true,
|
||||
cert: {
|
||||
id: plugins.crypto.randomUUID(),
|
||||
domainName: domain,
|
||||
created: certData.validFrom || Date.now(),
|
||||
validUntil: certData.validUntil || 0,
|
||||
privateKey: certData.privateKey,
|
||||
publicKey: certData.publicKey,
|
||||
csr: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, message: `No certificate data found for '${domain}'` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a certificate from ICert-shaped JSON
|
||||
*/
|
||||
private async importCertificate(cert: {
|
||||
id: string;
|
||||
domainName: string;
|
||||
created: number;
|
||||
validUntil: number;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
csr: string;
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
// Validate PEM content
|
||||
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
|
||||
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
|
||||
}
|
||||
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
|
||||
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
|
||||
}
|
||||
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
||||
|
||||
// Save to /certs/ (SmartAcme-compatible path)
|
||||
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
|
||||
id: cert.id,
|
||||
domainName: cert.domainName,
|
||||
created: cert.created,
|
||||
validUntil: cert.validUntil,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
csr: cert.csr || '',
|
||||
});
|
||||
|
||||
// Also save to /proxy-certs/ (proxy-cert format)
|
||||
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
|
||||
domain: cert.domainName,
|
||||
publicKey: cert.publicKey,
|
||||
privateKey: cert.privateKey,
|
||||
ca: undefined,
|
||||
validUntil: cert.validUntil,
|
||||
validFrom: cert.created,
|
||||
});
|
||||
|
||||
// Update in-memory status map
|
||||
dcRouter.certificateStatusMap.set(cert.domainName, {
|
||||
status: 'valid',
|
||||
source: 'static',
|
||||
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
|
||||
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
|
||||
routeNames: [],
|
||||
});
|
||||
|
||||
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class ConfigHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Config endpoint registers directly on viewRouter (valid identity required via middleware)
|
||||
const router = this.opsServerRef.viewRouter;
|
||||
|
||||
// Get Configuration Handler (read-only)
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
async (dataArg, toolsArg) => {
|
||||
const config = await this.getConfiguration(dataArg.section);
|
||||
const config = await this.getConfiguration();
|
||||
return {
|
||||
config,
|
||||
section: dataArg.section,
|
||||
@@ -27,82 +27,188 @@ export class ConfigHandler {
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfiguration(section?: string): Promise<{
|
||||
email: {
|
||||
enabled: boolean;
|
||||
ports: number[];
|
||||
maxMessageSize: number;
|
||||
rateLimits: {
|
||||
perMinute: number;
|
||||
perHour: number;
|
||||
perDay: number;
|
||||
};
|
||||
domains?: string[];
|
||||
};
|
||||
dns: {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
nameservers: string[];
|
||||
caching: boolean;
|
||||
ttl: number;
|
||||
};
|
||||
proxy: {
|
||||
enabled: boolean;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
maxConnections: number;
|
||||
};
|
||||
security: {
|
||||
blockList: string[];
|
||||
rateLimit: boolean;
|
||||
spamDetection: boolean;
|
||||
tlsRequired: boolean;
|
||||
};
|
||||
}> {
|
||||
private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const opts = dcRouter.options;
|
||||
const resolvedPaths = dcRouter.resolvedPaths;
|
||||
|
||||
// Get email domains if email server is configured
|
||||
// --- System ---
|
||||
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction
|
||||
? 'custom'
|
||||
: opts.storage?.fsPath
|
||||
? 'filesystem'
|
||||
: 'memory';
|
||||
|
||||
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
||||
let proxyIps = opts.proxyIps || [];
|
||||
if (proxyIps.length === 0 && dcRouter.smartProxy) {
|
||||
const spSettings = (dcRouter.smartProxy as any).settings;
|
||||
if (spSettings?.proxyIPs?.length > 0) {
|
||||
proxyIps = spSettings.proxyIPs;
|
||||
}
|
||||
}
|
||||
|
||||
const system: interfaces.requests.IConfigData['system'] = {
|
||||
baseDir: resolvedPaths.dcrouterHomeDir,
|
||||
dataDir: resolvedPaths.dataDir,
|
||||
publicIp: opts.publicIp || dcRouter.detectedPublicIp || null,
|
||||
proxyIps,
|
||||
uptime: Math.floor(process.uptime()),
|
||||
storageBackend,
|
||||
storagePath: opts.storage?.fsPath || null,
|
||||
};
|
||||
|
||||
// --- SmartProxy ---
|
||||
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
|
||||
if (opts.smartProxyConfig?.acme) {
|
||||
const acme = opts.smartProxyConfig.acme;
|
||||
acmeInfo = {
|
||||
enabled: acme.enabled !== false,
|
||||
accountEmail: acme.accountEmail || '',
|
||||
useProduction: acme.useProduction !== false,
|
||||
autoRenew: acme.autoRenew !== false,
|
||||
renewThresholdDays: acme.renewThresholdDays || 30,
|
||||
};
|
||||
}
|
||||
|
||||
let routeCount = 0;
|
||||
if (dcRouter.routeConfigManager) {
|
||||
try {
|
||||
const merged = await dcRouter.routeConfigManager.getMergedRoutes();
|
||||
routeCount = merged.routes.length;
|
||||
} catch {
|
||||
routeCount = opts.smartProxyConfig?.routes?.length || 0;
|
||||
}
|
||||
} else if (opts.smartProxyConfig?.routes) {
|
||||
routeCount = opts.smartProxyConfig.routes.length;
|
||||
}
|
||||
|
||||
const smartProxy: interfaces.requests.IConfigData['smartProxy'] = {
|
||||
enabled: !!dcRouter.smartProxy,
|
||||
routeCount,
|
||||
acme: acmeInfo,
|
||||
};
|
||||
|
||||
// --- Email ---
|
||||
let emailDomains: string[] = [];
|
||||
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) {
|
||||
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains();
|
||||
} else if (dcRouter.options.emailConfig?.domains) {
|
||||
// Fallback: get domains from email config options
|
||||
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
|
||||
if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
|
||||
emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
|
||||
} else if (opts.emailConfig?.domains) {
|
||||
emailDomains = opts.emailConfig.domains.map((d: any) =>
|
||||
typeof d === 'string' ? d : d.domain
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
email: {
|
||||
let portMapping: Record<string, number> | null = null;
|
||||
if (opts.emailPortConfig?.portMapping) {
|
||||
portMapping = {};
|
||||
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
|
||||
portMapping[String(ext)] = int as number;
|
||||
}
|
||||
}
|
||||
|
||||
const email: interfaces.requests.IConfigData['email'] = {
|
||||
enabled: !!dcRouter.emailServer,
|
||||
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [],
|
||||
maxMessageSize: 10 * 1024 * 1024, // 10MB default
|
||||
rateLimits: {
|
||||
perMinute: 10,
|
||||
perHour: 100,
|
||||
perDay: 1000,
|
||||
},
|
||||
ports: opts.emailConfig?.ports || [],
|
||||
portMapping,
|
||||
hostname: opts.emailConfig?.hostname || null,
|
||||
domains: emailDomains,
|
||||
},
|
||||
dns: {
|
||||
emailRouteCount: opts.emailConfig?.routes?.length || 0,
|
||||
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
|
||||
};
|
||||
|
||||
// --- DNS ---
|
||||
const dnsRecords = (opts.dnsRecords || []).map(r => ({
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
value: r.value,
|
||||
ttl: r.ttl,
|
||||
}));
|
||||
|
||||
const dns: interfaces.requests.IConfigData['dns'] = {
|
||||
enabled: !!dcRouter.dnsServer,
|
||||
port: 53,
|
||||
nameservers: dcRouter.options.dnsNsDomains || [],
|
||||
caching: true,
|
||||
ttl: 300,
|
||||
},
|
||||
proxy: {
|
||||
enabled: !!dcRouter.smartProxy,
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
maxConnections: 1000,
|
||||
},
|
||||
security: {
|
||||
blockList: [],
|
||||
rateLimit: true,
|
||||
spamDetection: true,
|
||||
tlsRequired: false,
|
||||
},
|
||||
nsDomains: opts.dnsNsDomains || [],
|
||||
scopes: opts.dnsScopes || [],
|
||||
recordCount: dnsRecords.length,
|
||||
records: dnsRecords,
|
||||
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
|
||||
};
|
||||
|
||||
// --- TLS ---
|
||||
let tlsSource: 'acme' | 'static' | 'none' = 'none';
|
||||
if (opts.tls?.certPath && opts.tls?.keyPath) {
|
||||
tlsSource = 'static';
|
||||
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
|
||||
tlsSource = 'acme';
|
||||
}
|
||||
|
||||
const tls: interfaces.requests.IConfigData['tls'] = {
|
||||
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
|
||||
domain: opts.tls?.domain || null,
|
||||
source: tlsSource,
|
||||
certPath: opts.tls?.certPath || null,
|
||||
keyPath: opts.tls?.keyPath || null,
|
||||
};
|
||||
|
||||
// --- Cache ---
|
||||
const cacheConfig = opts.cacheConfig;
|
||||
const cache: interfaces.requests.IConfigData['cache'] = {
|
||||
enabled: cacheConfig?.enabled !== false,
|
||||
storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||
dbName: cacheConfig?.dbName || 'dcrouter',
|
||||
defaultTTLDays: cacheConfig?.defaultTTLDays || 30,
|
||||
cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1,
|
||||
ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record<string, number> : {},
|
||||
};
|
||||
|
||||
// --- RADIUS ---
|
||||
const radiusCfg = opts.radiusConfig;
|
||||
const radius: interfaces.requests.IConfigData['radius'] = {
|
||||
enabled: !!dcRouter.radiusServer,
|
||||
authPort: radiusCfg?.authPort || null,
|
||||
acctPort: radiusCfg?.acctPort || null,
|
||||
bindAddress: radiusCfg?.bindAddress || null,
|
||||
clientCount: radiusCfg?.clients?.length || 0,
|
||||
vlanDefaultVlan: radiusCfg?.vlanAssignment?.defaultVlan ?? null,
|
||||
vlanAllowUnknownMacs: radiusCfg?.vlanAssignment?.allowUnknownMacs ?? null,
|
||||
vlanMappingCount: radiusCfg?.vlanAssignment?.mappings?.length || 0,
|
||||
};
|
||||
|
||||
// --- Remote Ingress ---
|
||||
const riCfg = opts.remoteIngressConfig;
|
||||
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
|
||||
|
||||
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
|
||||
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
|
||||
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
|
||||
tlsMode = 'custom';
|
||||
} else if (riCfg?.hubDomain) {
|
||||
try {
|
||||
const stored = await dcRouter.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
|
||||
if (stored?.publicKey && stored?.privateKey) {
|
||||
tlsMode = 'acme';
|
||||
}
|
||||
} catch { /* no stored cert */ }
|
||||
}
|
||||
|
||||
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
|
||||
enabled: !!dcRouter.remoteIngressManager,
|
||||
tunnelPort: riCfg?.tunnelPort || null,
|
||||
hubDomain: riCfg?.hubDomain || null,
|
||||
tlsMode,
|
||||
connectedEdgeIps,
|
||||
};
|
||||
|
||||
return {
|
||||
system,
|
||||
smartProxy,
|
||||
email,
|
||||
dns,
|
||||
tls,
|
||||
cache,
|
||||
radius,
|
||||
remoteIngress,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,44 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { SecurityLogger } from '../../security/index.js';
|
||||
|
||||
export class EmailOpsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Queued Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
|
||||
'getQueuedEmails',
|
||||
const viewRouter = this.opsServerRef.viewRouter;
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
|
||||
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||
|
||||
// Get All Emails Handler
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
|
||||
'getAllEmails',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const stats = queue.getStats();
|
||||
|
||||
// Get all queue items and filter by status if provided
|
||||
const items = this.getQueueItems(
|
||||
dataArg.status,
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: stats.queueSize,
|
||||
};
|
||||
const emails = this.getAllQueueEmails();
|
||||
return { emails };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Sent Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
|
||||
'getSentEmails',
|
||||
// Get Email Detail Handler
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
|
||||
'getEmailDetail',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'delivered',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length, // Note: total would ideally come from a counter
|
||||
};
|
||||
const email = this.getEmailDetail(dataArg.emailId);
|
||||
return { email };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Failed Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
|
||||
'getFailedEmails',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'failed',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
// ---- Write endpoints (adminRouter) ----
|
||||
|
||||
// Resend Failed Email Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
async (dataArg) => {
|
||||
@@ -101,17 +59,12 @@ export class EmailOpsHandler {
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-enqueue the failed email by creating a new queue entry
|
||||
// with the same data but reset attempt count
|
||||
const newQueueId = await queue.enqueue(
|
||||
item.processingResult,
|
||||
item.processingMode,
|
||||
item.route
|
||||
);
|
||||
|
||||
// Optionally remove the old failed entry
|
||||
await queue.removeItem(dataArg.emailId);
|
||||
|
||||
return { success: true, newQueueId };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -122,197 +75,199 @@ export class EmailOpsHandler {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Security Incidents Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
|
||||
'getSecurityIncidents',
|
||||
async (dataArg) => {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
|
||||
const filter: {
|
||||
level?: any;
|
||||
type?: any;
|
||||
} = {};
|
||||
|
||||
if (dataArg.level) {
|
||||
filter.level = dataArg.level;
|
||||
}
|
||||
|
||||
if (dataArg.type) {
|
||||
filter.type = dataArg.type;
|
||||
}
|
||||
|
||||
const incidents = securityLogger.getRecentEvents(
|
||||
dataArg.limit || 100,
|
||||
Object.keys(filter).length > 0 ? filter : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
incidents: incidents.map(event => ({
|
||||
timestamp: event.timestamp,
|
||||
level: event.level as interfaces.requests.TSecurityLogLevel,
|
||||
type: event.type as interfaces.requests.TSecurityEventType,
|
||||
message: event.message,
|
||||
details: event.details,
|
||||
ipAddress: event.ipAddress,
|
||||
userId: event.userId,
|
||||
sessionId: event.sessionId,
|
||||
emailId: event.emailId,
|
||||
domain: event.domain,
|
||||
action: event.action,
|
||||
result: event.result,
|
||||
success: event.success,
|
||||
})),
|
||||
total: incidents.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Bounce Records Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
|
||||
'getBounceRecords',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
|
||||
if (!emailServer) {
|
||||
return { records: [], suppressionList: [], total: 0 };
|
||||
}
|
||||
|
||||
// Use smartmta's public API for bounce/suppression data
|
||||
const suppressionList = emailServer.getSuppressionList();
|
||||
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
|
||||
|
||||
// Create bounce records from the available data
|
||||
const records: interfaces.requests.IBounceRecord[] = [];
|
||||
|
||||
for (const email of hardBouncedAddresses) {
|
||||
const bounceInfo = emailServer.getBounceHistory(email);
|
||||
if (bounceInfo) {
|
||||
records.push({
|
||||
id: `bounce-${email}`,
|
||||
recipient: email,
|
||||
sender: '',
|
||||
domain: email.split('@')[1] || '',
|
||||
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
|
||||
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
|
||||
timestamp: (bounceInfo as any).lastBounce,
|
||||
processed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit and offset
|
||||
const limit = dataArg.limit || 50;
|
||||
const offset = dataArg.offset || 0;
|
||||
const paginatedRecords = records.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
records: paginatedRecords,
|
||||
suppressionList,
|
||||
total: records.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove from Suppression List Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
|
||||
'removeFromSuppressionList',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
|
||||
if (!emailServer) {
|
||||
return { success: false, error: 'Email server not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
emailServer.removeFromSuppressionList(dataArg.email);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get queue items with filtering and pagination
|
||||
* Get all queue items mapped to catalog IEmail format
|
||||
*/
|
||||
private getQueueItems(
|
||||
status?: interfaces.requests.TEmailQueueStatus,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): interfaces.requests.IEmailQueueItem[] {
|
||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const items: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
// Access the internal queue map via reflection
|
||||
// This is necessary because the queue doesn't expose iteration methods
|
||||
const queueMap = (queue as any).queue as Map<string, any>;
|
||||
|
||||
if (!queueMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter and convert items
|
||||
const emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
for (const [id, item] of queueMap.entries()) {
|
||||
// Apply status filter if provided
|
||||
if (status && item.status !== status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email details from processingResult if available
|
||||
const processingResult = item.processingResult;
|
||||
let from = '';
|
||||
let to: string[] = [];
|
||||
let subject = '';
|
||||
|
||||
if (processingResult) {
|
||||
// Check if it's an Email object or raw email data
|
||||
if (processingResult.email) {
|
||||
from = processingResult.email.from || '';
|
||||
to = processingResult.email.to || [];
|
||||
subject = processingResult.email.subject || '';
|
||||
} else if (processingResult.from) {
|
||||
from = processingResult.from;
|
||||
to = processingResult.to || [];
|
||||
subject = processingResult.subject || '';
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: item.id,
|
||||
processingMode: item.processingMode,
|
||||
status: item.status,
|
||||
attempts: item.attempts,
|
||||
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
|
||||
lastError: item.lastError,
|
||||
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
|
||||
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
|
||||
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
});
|
||||
emails.push(this.mapQueueItemToEmail(item));
|
||||
}
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
// Apply pagination
|
||||
return items.slice(offset, offset + limit);
|
||||
return emails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single email detail by ID
|
||||
*/
|
||||
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(emailId);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapQueueItemToEmailDetail(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a queue item to catalog IEmail format
|
||||
*/
|
||||
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
|
||||
const processingResult = item.processingResult;
|
||||
let from = '';
|
||||
let to = '';
|
||||
let subject = '';
|
||||
let messageId = '';
|
||||
let size = '0 B';
|
||||
|
||||
if (processingResult) {
|
||||
if (processingResult.email) {
|
||||
from = processingResult.email.from || '';
|
||||
to = (processingResult.email.to || [])[0] || '';
|
||||
subject = processingResult.email.subject || '';
|
||||
} else if (processingResult.from) {
|
||||
from = processingResult.from;
|
||||
to = (processingResult.to || [])[0] || '';
|
||||
subject = processingResult.subject || '';
|
||||
}
|
||||
|
||||
// Try to get messageId
|
||||
if (typeof processingResult.getMessageId === 'function') {
|
||||
try {
|
||||
messageId = processingResult.getMessageId() || '';
|
||||
} catch {
|
||||
messageId = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Compute approximate size
|
||||
const textLen = processingResult.text?.length || 0;
|
||||
const htmlLen = processingResult.html?.length || 0;
|
||||
let attachSize = 0;
|
||||
if (typeof processingResult.getAttachmentsSize === 'function') {
|
||||
try {
|
||||
attachSize = processingResult.getAttachmentsSize() || 0;
|
||||
} catch {
|
||||
attachSize = 0;
|
||||
}
|
||||
}
|
||||
size = this.formatSize(textLen + htmlLen + attachSize);
|
||||
}
|
||||
|
||||
// Map queue status to catalog TEmailStatus
|
||||
const status = this.mapStatus(item.status);
|
||||
|
||||
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
direction: 'outbound' as interfaces.requests.TEmailDirection,
|
||||
status,
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
timestamp: new Date(createdAt).toISOString(),
|
||||
messageId,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a queue item to catalog IEmailDetail format
|
||||
*/
|
||||
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
|
||||
const base = this.mapQueueItemToEmail(item);
|
||||
const processingResult = item.processingResult;
|
||||
|
||||
let toList: string[] = [];
|
||||
let cc: string[] = [];
|
||||
let headers: Record<string, string> = {};
|
||||
let body = '';
|
||||
|
||||
if (processingResult) {
|
||||
if (processingResult.email) {
|
||||
toList = processingResult.email.to || [];
|
||||
cc = processingResult.email.cc || [];
|
||||
} else {
|
||||
toList = processingResult.to || [];
|
||||
cc = processingResult.cc || [];
|
||||
}
|
||||
|
||||
headers = processingResult.headers || {};
|
||||
body = processingResult.html || processingResult.text || '';
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
toList,
|
||||
cc,
|
||||
smtpLog: [],
|
||||
connectionInfo: {
|
||||
sourceIp: '',
|
||||
sourceHostname: '',
|
||||
destinationIp: '',
|
||||
destinationPort: 0,
|
||||
tlsVersion: '',
|
||||
tlsCipher: '',
|
||||
authenticated: false,
|
||||
authMethod: '',
|
||||
authUser: '',
|
||||
},
|
||||
authenticationResults: {
|
||||
spf: 'none',
|
||||
spfDomain: '',
|
||||
dkim: 'none',
|
||||
dkimDomain: '',
|
||||
dmarc: 'none',
|
||||
dmarcPolicy: '',
|
||||
},
|
||||
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
|
||||
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map queue status to catalog TEmailStatus
|
||||
*/
|
||||
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
|
||||
switch (queueStatus) {
|
||||
case 'pending':
|
||||
case 'processing':
|
||||
return 'pending';
|
||||
case 'delivered':
|
||||
return 'delivered';
|
||||
case 'failed':
|
||||
return 'bounced';
|
||||
case 'deferred':
|
||||
return 'deferred';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format byte size to human-readable string
|
||||
*/
|
||||
private formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,7 @@ export * from './security.handler.js';
|
||||
export * from './stats.handler.js';
|
||||
export * from './radius.handler.js';
|
||||
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';
|
||||
@@ -1,19 +1,42 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { logBuffer, baseLogger } from '../../logger.js';
|
||||
|
||||
// Module-level singleton: the log push destination is added once and reuses
|
||||
// the current OpsServer reference so it survives OpsServer restarts without
|
||||
// accumulating duplicate destinations.
|
||||
let logPushDestinationInstalled = false;
|
||||
let currentOpsServerRef: OpsServer | null = null;
|
||||
|
||||
export class LogsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private activeStreamStops: Set<() => void> = new Set();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
this.setupLogPushDestination();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all active log streams and deactivate the push destination.
|
||||
* Called when OpsServer stops.
|
||||
*/
|
||||
public cleanup(): void {
|
||||
// Stop all active follow-mode log streams
|
||||
for (const stop of this.activeStreamStops) {
|
||||
stop();
|
||||
}
|
||||
this.activeStreamStops.clear();
|
||||
// Deactivate the push destination (it stays registered but becomes a no-op)
|
||||
currentOpsServerRef = null;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// All log endpoints register directly on viewRouter (valid identity required via middleware)
|
||||
const router = this.opsServerRef.viewRouter;
|
||||
|
||||
// Get Recent Logs Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'getRecentLogs',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -28,15 +51,15 @@ export class LogsHandler {
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: logs.length, // TODO: Implement proper total count
|
||||
hasMore: false, // TODO: Implement proper pagination
|
||||
total: logs.length,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Log Stream Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
||||
'getLogStream',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -54,16 +77,43 @@ export class LogsHandler {
|
||||
// Start streaming
|
||||
streamLogs.start();
|
||||
|
||||
// VirtualStream handles cleanup automatically
|
||||
// Track the stop function so we can clean up on shutdown
|
||||
this.activeStreamStops.add(streamLogs.stop);
|
||||
|
||||
return {
|
||||
logStream: virtualStream as any, // Cast to IVirtualStream interface
|
||||
logStream: virtualStream as any,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
|
||||
switch (smartlogLevel) {
|
||||
case 'silly':
|
||||
case 'debug':
|
||||
return 'debug';
|
||||
case 'warn':
|
||||
return 'warn';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
private static deriveCategory(
|
||||
zone?: string,
|
||||
message?: string
|
||||
): 'smtp' | 'dns' | 'security' | 'system' | 'email' {
|
||||
const msg = (message || '').toLowerCase();
|
||||
if (msg.includes('[security:') || msg.includes('security')) return 'security';
|
||||
if (zone === 'email' || msg.includes('email') || msg.includes('smtp') || msg.includes('mta')) return 'email';
|
||||
if (zone === 'dns' || msg.includes('dns')) return 'dns';
|
||||
if (msg.includes('smtp')) return 'smtp';
|
||||
return 'system';
|
||||
}
|
||||
|
||||
private async getRecentLogs(
|
||||
level?: 'error' | 'warn' | 'info' | 'debug',
|
||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
|
||||
@@ -78,9 +128,39 @@ export class LogsHandler {
|
||||
message: string;
|
||||
metadata?: any;
|
||||
}>> {
|
||||
// TODO: Implement actual log retrieval from storage or logger
|
||||
// For now, return mock data
|
||||
const mockLogs: Array<{
|
||||
// Compute a timestamp cutoff from timeRange
|
||||
let since: number | undefined;
|
||||
if (timeRange) {
|
||||
const rangeMs: Record<string, number> = {
|
||||
'1h': 3600000,
|
||||
'6h': 21600000,
|
||||
'24h': 86400000,
|
||||
'7d': 604800000,
|
||||
'30d': 2592000000,
|
||||
};
|
||||
since = Date.now() - (rangeMs[timeRange] || 86400000);
|
||||
}
|
||||
|
||||
// Map the UI level to smartlog levels for filtering
|
||||
const smartlogLevels: string[] | undefined = level
|
||||
? level === 'debug'
|
||||
? ['debug', 'silly']
|
||||
: level === 'info'
|
||||
? ['info', 'ok', 'success', 'note', 'lifecycle']
|
||||
: [level]
|
||||
: undefined;
|
||||
|
||||
// Fetch a larger batch from buffer, then apply category filter client-side
|
||||
const rawEntries = logBuffer.getEntries({
|
||||
level: smartlogLevels as any,
|
||||
search,
|
||||
since,
|
||||
limit: limit * 3, // over-fetch to compensate for category filtering
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Map ILogPackage → UI log format and apply category filter
|
||||
const mapped: Array<{
|
||||
timestamp: number;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
@@ -88,32 +168,80 @@ export class LogsHandler {
|
||||
metadata?: any;
|
||||
}> = [];
|
||||
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
const now = Date.now();
|
||||
for (const pkg of rawEntries) {
|
||||
const uiLevel = LogsHandler.mapLogLevel(pkg.level);
|
||||
const uiCategory = LogsHandler.deriveCategory(pkg.context?.zone, pkg.message);
|
||||
|
||||
// Generate some mock log entries
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||
if (category && uiCategory !== category) continue;
|
||||
|
||||
// Filter by requested criteria
|
||||
if (level && mockLevel !== level) continue;
|
||||
if (category && mockCategory !== category) continue;
|
||||
|
||||
mockLogs.push({
|
||||
timestamp: now - (i * 60000), // 1 minute apart
|
||||
level: mockLevel,
|
||||
category: mockCategory,
|
||||
message: `Sample log message ${i} from ${mockCategory}`,
|
||||
metadata: {
|
||||
requestId: plugins.uuid.v4(),
|
||||
},
|
||||
mapped.push({
|
||||
timestamp: pkg.timestamp,
|
||||
level: uiLevel,
|
||||
category: uiCategory,
|
||||
message: pkg.message,
|
||||
metadata: pkg.data,
|
||||
});
|
||||
|
||||
if (mapped.length >= limit) break;
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
return mockLogs.slice(offset, offset + limit);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log destination to the base logger that pushes entries
|
||||
* to all connected ops_dashboard TypedSocket clients.
|
||||
*
|
||||
* Uses a module-level singleton so the destination is added only once,
|
||||
* even across OpsServer restart cycles. The destination reads
|
||||
* `currentOpsServerRef` dynamically so it always uses the active server.
|
||||
*/
|
||||
private setupLogPushDestination(): void {
|
||||
// Update the module-level reference so the existing destination uses the new server
|
||||
currentOpsServerRef = this.opsServerRef;
|
||||
|
||||
if (logPushDestinationInstalled) {
|
||||
return; // destination already registered — just updated the ref
|
||||
}
|
||||
logPushDestinationInstalled = true;
|
||||
|
||||
baseLogger.addLogDestination({
|
||||
async handleLog(logPackage: any) {
|
||||
const opsServer = currentOpsServerRef;
|
||||
if (!opsServer) return;
|
||||
|
||||
const typedsocket = opsServer.server?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
|
||||
let connections: any[];
|
||||
try {
|
||||
connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (connections.length === 0) return;
|
||||
|
||||
const entry: interfaces.data.ILogEntry = {
|
||||
timestamp: logPackage.timestamp || Date.now(),
|
||||
level: LogsHandler.mapLogLevel(logPackage.level),
|
||||
category: LogsHandler.deriveCategory(logPackage.context?.zone, logPackage.message),
|
||||
message: logPackage.message,
|
||||
metadata: logPackage.data,
|
||||
};
|
||||
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
const push = typedsocket.createTypedRequest<interfaces.requests.IReq_PushLogEntry>(
|
||||
'pushLogEntry',
|
||||
conn,
|
||||
);
|
||||
push.fire({ entry }).catch(() => {}); // fire-and-forget
|
||||
} catch {
|
||||
// connection may have closed
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private setupLogStream(
|
||||
@@ -126,8 +254,18 @@ export class LogsHandler {
|
||||
stop: () => void;
|
||||
} {
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
let stopped = false;
|
||||
let logIndex = 0;
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
this.activeStreamStops.delete(stop);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (!follow) {
|
||||
// Send existing logs and close
|
||||
@@ -142,13 +280,19 @@ export class LogsHandler {
|
||||
const encoder = new TextEncoder();
|
||||
virtualStream.sendData(encoder.encode(logData));
|
||||
});
|
||||
// VirtualStream doesn't have end() method - it closes automatically
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For follow mode, simulate real-time log streaming
|
||||
intervalId = setInterval(() => {
|
||||
intervalId = setInterval(async () => {
|
||||
if (stopped) {
|
||||
// Guard: clear interval if stop() was called between ticks
|
||||
clearInterval(intervalId!);
|
||||
intervalId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
|
||||
@@ -171,23 +315,24 @@ export class LogsHandler {
|
||||
|
||||
const logData = JSON.stringify(logEntry);
|
||||
const encoder = new TextEncoder();
|
||||
virtualStream.sendData(encoder.encode(logData));
|
||||
}, 2000); // Send a log every 2 seconds
|
||||
|
||||
// TODO: Hook into actual logger events
|
||||
// logger.on('log', (logEntry) => {
|
||||
// if (matchesCriteria(logEntry, level, service)) {
|
||||
// virtualStream.sendData(formatLogEntry(logEntry));
|
||||
// }
|
||||
// });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
try {
|
||||
// Use a timeout to detect hung streams (sendData can hang if the
|
||||
// VirtualStream's keepAlive loop has ended)
|
||||
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
await Promise.race([
|
||||
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
return result;
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// Stream closed, errored, or timed out — clean up
|
||||
stop();
|
||||
}
|
||||
// TODO: Unhook from logger events
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return { start, stop };
|
||||
|
||||
@@ -3,21 +3,19 @@ import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class RadiusHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
const viewRouter = this.opsServerRef.viewRouter;
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
// ========================================================================
|
||||
// RADIUS Client Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all RADIUS clients
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get all RADIUS clients (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||
'getRadiusClients',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -40,8 +38,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Add or update a RADIUS client (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||
'setRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -61,8 +59,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Remove a RADIUS client (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||
'removeRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -85,8 +83,8 @@ export class RadiusHandler {
|
||||
// VLAN Mapping Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all VLAN mappings
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get all VLAN mappings (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||
'getVlanMappings',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -121,8 +119,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Add or update a VLAN mapping (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||
'setVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -153,8 +151,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Remove a VLAN mapping (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||
'removeVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -174,8 +172,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Update VLAN configuration
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Update VLAN configuration (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||
'updateVlanConfig',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -206,8 +204,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Test VLAN assignment
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Test VLAN assignment (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||
'testVlanAssignment',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -240,8 +238,8 @@ export class RadiusHandler {
|
||||
// Accounting / Session Management
|
||||
// ========================================================================
|
||||
|
||||
// Get active sessions
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get active sessions (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||
'getRadiusSessions',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -289,8 +287,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Disconnect a session
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Disconnect a session (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||
'disconnectRadiusSession',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -314,8 +312,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Get accounting summary
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get accounting summary (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||
'getRadiusAccountingSummary',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -351,8 +349,8 @@ export class RadiusHandler {
|
||||
// Statistics
|
||||
// ========================================================================
|
||||
|
||||
// Get RADIUS statistics
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get RADIUS statistics (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||
'getRadiusStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
|
||||
226
ts/opsserver/handlers/remoteingress.handler.ts
Normal file
226
ts/opsserver/handlers/remoteingress.handler.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class RemoteIngressHandler {
|
||||
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 remote ingress edges
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
|
||||
'getRemoteIngresses',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
if (!manager) {
|
||||
return { edges: [] };
|
||||
}
|
||||
// Return edges without secrets, enriched with effective listen ports and breakdown
|
||||
const edges = manager.getAllEdges().map((e) => {
|
||||
const breakdown = manager.getPortBreakdown(e);
|
||||
return {
|
||||
...e,
|
||||
secret: '********', // Never expose secrets via API
|
||||
effectiveListenPorts: manager.getEffectiveListenPorts(e),
|
||||
manualPorts: breakdown.manual,
|
||||
derivedPorts: breakdown.derived,
|
||||
};
|
||||
});
|
||||
return { edges };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ---- Write endpoints (adminRouter) ----
|
||||
|
||||
// Create a new remote ingress edge
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
|
||||
'createRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
|
||||
if (!manager) {
|
||||
return {
|
||||
success: false,
|
||||
edge: null as any,
|
||||
};
|
||||
}
|
||||
|
||||
const edge = await manager.createEdge(
|
||||
dataArg.name,
|
||||
dataArg.listenPorts || [],
|
||||
dataArg.tags,
|
||||
dataArg.autoDerivePorts ?? true,
|
||||
);
|
||||
|
||||
// Sync allowed edges with the hub
|
||||
if (tunnelManager) {
|
||||
await tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
|
||||
return { success: true, edge };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete a remote ingress edge
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
|
||||
'deleteRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
|
||||
if (!manager) {
|
||||
return { success: false, message: 'RemoteIngress not configured' };
|
||||
}
|
||||
|
||||
const deleted = await manager.deleteEdge(dataArg.id);
|
||||
if (deleted && tunnelManager) {
|
||||
await tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
|
||||
return {
|
||||
success: deleted,
|
||||
message: deleted ? undefined : 'Edge not found',
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update a remote ingress edge
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
|
||||
'updateRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
|
||||
if (!manager) {
|
||||
return { success: false, edge: null as any };
|
||||
}
|
||||
|
||||
const edge = await manager.updateEdge(dataArg.id, {
|
||||
name: dataArg.name,
|
||||
listenPorts: dataArg.listenPorts,
|
||||
autoDerivePorts: dataArg.autoDerivePorts,
|
||||
enabled: dataArg.enabled,
|
||||
tags: dataArg.tags,
|
||||
});
|
||||
|
||||
if (!edge) {
|
||||
return { success: false, edge: null as any };
|
||||
}
|
||||
|
||||
// Sync allowed edges — ports, tags, or enabled may have changed
|
||||
if (tunnelManager) {
|
||||
await tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
|
||||
const breakdown = manager.getPortBreakdown(edge);
|
||||
return {
|
||||
success: true,
|
||||
edge: {
|
||||
...edge,
|
||||
secret: '********',
|
||||
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
|
||||
manualPorts: breakdown.manual,
|
||||
derivedPorts: breakdown.derived,
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Regenerate secret for an edge
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
||||
'regenerateRemoteIngressSecret',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
|
||||
if (!manager) {
|
||||
return { success: false, secret: '' };
|
||||
}
|
||||
|
||||
const secret = await manager.regenerateSecret(dataArg.id);
|
||||
if (!secret) {
|
||||
return { success: false, secret: '' };
|
||||
}
|
||||
|
||||
// Sync allowed edges since secret changed
|
||||
if (tunnelManager) {
|
||||
await tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
|
||||
return { success: true, secret };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get runtime status of all edges (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
||||
'getRemoteIngressStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||
if (!tunnelManager) {
|
||||
return { statuses: [] };
|
||||
}
|
||||
return { statuses: tunnelManager.getEdgeStatuses() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get a connection token for an edge (write — exposes secret)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
||||
'getRemoteIngressConnectionToken',
|
||||
async (dataArg, toolsArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'RemoteIngress not configured' };
|
||||
}
|
||||
|
||||
const edge = manager.getEdge(dataArg.edgeId);
|
||||
if (!edge) {
|
||||
return { success: false, message: 'Edge not found' };
|
||||
}
|
||||
if (!edge.enabled) {
|
||||
return { success: false, message: 'Edge is disabled' };
|
||||
}
|
||||
|
||||
const hubHost = dataArg.hubHost
|
||||
|| this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.hubDomain;
|
||||
if (!hubHost) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No hub hostname configured. Set hubDomain in remoteIngressConfig or provide hubHost.',
|
||||
};
|
||||
}
|
||||
|
||||
const hubPort = this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.tunnelPort ?? 8443;
|
||||
|
||||
const token = plugins.remoteingress.encodeConnectionToken({
|
||||
hubHost,
|
||||
hubPort,
|
||||
edgeId: edge.id,
|
||||
secret: edge.secret,
|
||||
});
|
||||
|
||||
return { success: true, token };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
163
ts/opsserver/handlers/route-management.handler.ts
Normal file
163
ts/opsserver/handlers/route-management.handler.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class RouteManagementHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate auth: JWT identity OR API token with required scope.
|
||||
* Returns a userId string on success, throws on failure.
|
||||
*/
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
// Try JWT identity first
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// Try API token
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get merged routes
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetMergedRoutes>(
|
||||
'getMergedRoutes',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { routes: [], warnings: [] };
|
||||
}
|
||||
return manager.getMergedRoutes();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
|
||||
'createRoute',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true);
|
||||
return { success: true, storedRouteId: id };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRoute>(
|
||||
'updateRoute',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.updateRoute(dataArg.id, {
|
||||
route: dataArg.route as any,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRoute>(
|
||||
'deleteRoute',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.deleteRoute(dataArg.id);
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Set override on a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Remove override from a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.removeOverride(dataArg.routeName);
|
||||
return { success: ok, message: ok ? undefined : 'Override not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Toggle programmatic route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,16 @@ import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
|
||||
export class SecurityHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// All security endpoints register directly on viewRouter (valid identity required via middleware)
|
||||
const router = this.opsServerRef.viewRouter;
|
||||
|
||||
// Security Metrics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||
'getSecurityMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -40,7 +39,7 @@ export class SecurityHandler {
|
||||
);
|
||||
|
||||
// Active Connections Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
||||
'getActiveConnections',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -77,19 +76,31 @@ export class SecurityHandler {
|
||||
);
|
||||
|
||||
// Network Stats Handler - provides comprehensive network metrics
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
|
||||
'getNetworkStats',
|
||||
async (dataArg, toolsArg) => {
|
||||
// Get network stats from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
|
||||
// Convert per-IP throughput Map to serializable array
|
||||
const throughputByIP: Array<{ ip: string; in: number; out: number }> = [];
|
||||
if (networkStats.throughputByIP) {
|
||||
for (const [ip, tp] of networkStats.throughputByIP) {
|
||||
throughputByIP.push({ ip, in: tp.in, out: tp.out });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
||||
throughputRate: networkStats.throughputRate,
|
||||
topIPs: networkStats.topIPs,
|
||||
totalDataTransferred: networkStats.totalDataTransferred,
|
||||
throughputHistory: networkStats.throughputHistory || [],
|
||||
throughputByIP,
|
||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||
requestsTotal: networkStats.requestsTotal || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,13 +110,17 @@ export class SecurityHandler {
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [],
|
||||
throughputByIP: [],
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Rate Limit Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||
'getRateLimitStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
|
||||
@@ -2,19 +2,19 @@ import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||
|
||||
export class StatsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// All stats endpoints register directly on viewRouter (valid identity required via middleware)
|
||||
const router = this.opsServerRef.viewRouter;
|
||||
|
||||
// Server Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'getServerStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -27,6 +27,8 @@ export class StatsHandler {
|
||||
cpuUsage: stats.cpuUsage,
|
||||
activeConnections: stats.activeConnections,
|
||||
totalConnections: stats.totalConnections,
|
||||
requestsPerSecond: stats.requestsPerSecond,
|
||||
throughput: stats.throughput,
|
||||
},
|
||||
history: dataArg.includeHistory ? stats.history : undefined,
|
||||
};
|
||||
@@ -35,7 +37,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// Email Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
||||
'getEmailStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -74,7 +76,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// DNS Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
||||
'getDnsStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -111,7 +113,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// Queue Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
||||
'getQueueStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -139,7 +141,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// Health Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'getHealthStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -164,7 +166,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// Combined Metrics Handler - More efficient for frontend polling
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||
'getCombinedMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -191,6 +193,8 @@ export class StatsHandler {
|
||||
cpuUsage: stats.cpuUsage,
|
||||
activeConnections: stats.activeConnections,
|
||||
totalConnections: stats.totalConnections,
|
||||
requestsPerSecond: stats.requestsPerSecond,
|
||||
throughput: stats.throughput,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -199,6 +203,11 @@ export class StatsHandler {
|
||||
if (sections.email) {
|
||||
promises.push(
|
||||
this.collectEmailStats().then(stats => {
|
||||
// Get time-series data from MetricsManager
|
||||
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
|
||||
? this.opsServerRef.dcRouterRef.metricsManager.getEmailTimeSeries(24)
|
||||
: undefined;
|
||||
|
||||
metrics.email = {
|
||||
sent: stats.sentToday,
|
||||
received: stats.receivedToday,
|
||||
@@ -208,6 +217,7 @@ export class StatsHandler {
|
||||
averageDeliveryTime: 0,
|
||||
deliveryRate: stats.deliveryRate,
|
||||
bounceRate: stats.bounceRate,
|
||||
timeSeries,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -216,6 +226,11 @@ export class StatsHandler {
|
||||
if (sections.dns) {
|
||||
promises.push(
|
||||
this.collectDnsStats().then(stats => {
|
||||
// Get time-series data from MetricsManager
|
||||
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
|
||||
? this.opsServerRef.dcRouterRef.metricsManager.getDnsTimeSeries(24)
|
||||
: undefined;
|
||||
|
||||
metrics.dns = {
|
||||
totalQueries: stats.totalQueries,
|
||||
cacheHits: stats.cacheHits,
|
||||
@@ -224,6 +239,8 @@ export class StatsHandler {
|
||||
activeDomains: stats.topDomains.length,
|
||||
averageResponseTime: 0,
|
||||
queryTypes: stats.queryTypes,
|
||||
timeSeries,
|
||||
recentQueries: stats.recentQueries,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -232,6 +249,19 @@ export class StatsHandler {
|
||||
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
|
||||
// Get recent events from the SecurityLogger singleton
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
const recentEvents = securityLogger.getRecentEvents(50).map((evt) => ({
|
||||
timestamp: evt.timestamp,
|
||||
level: evt.level,
|
||||
type: evt.type,
|
||||
message: evt.message,
|
||||
details: evt.details,
|
||||
ipAddress: evt.ipAddress,
|
||||
domain: evt.domain,
|
||||
success: evt.success,
|
||||
}));
|
||||
|
||||
metrics.security = {
|
||||
blockedIPs: stats.blockedIPs,
|
||||
reputationScores: {},
|
||||
@@ -240,6 +270,7 @@ export class StatsHandler {
|
||||
phishingDetected: stats.phishingDetected,
|
||||
authenticationFailures: stats.authFailures,
|
||||
suspiciousActivities: stats.totalThreatsBlocked,
|
||||
recentEvents,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -247,36 +278,39 @@ export class StatsHandler {
|
||||
|
||||
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats().then(stats => {
|
||||
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
|
||||
stats.connectionsByIP.forEach((count, ip) => {
|
||||
connectionDetails.push({
|
||||
remoteAddress: ip,
|
||||
protocol: 'https' as any,
|
||||
state: 'established' as any,
|
||||
startTime: Date.now(),
|
||||
bytesIn: 0,
|
||||
bytesOut: 0,
|
||||
});
|
||||
});
|
||||
(async () => {
|
||||
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
const serverStats = await this.collectServerStats();
|
||||
|
||||
// Build per-IP bandwidth lookup from throughputByIP
|
||||
const ipBandwidth = new Map<string, { in: number; out: number }>();
|
||||
if (stats.throughputByIP) {
|
||||
for (const [ip, tp] of stats.throughputByIP) {
|
||||
ipBandwidth.set(ip, { in: tp.in, out: tp.out });
|
||||
}
|
||||
}
|
||||
|
||||
metrics.network = {
|
||||
totalBandwidth: {
|
||||
in: stats.throughputRate.bytesInPerSecond,
|
||||
out: stats.throughputRate.bytesOutPerSecond,
|
||||
},
|
||||
activeConnections: stats.connectionsByIP.size,
|
||||
connectionDetails: connectionDetails.slice(0, 50), // Limit to 50 connections
|
||||
totalBytes: {
|
||||
in: stats.totalDataTransferred.bytesIn,
|
||||
out: stats.totalDataTransferred.bytesOut,
|
||||
},
|
||||
activeConnections: serverStats.activeConnections,
|
||||
connectionDetails: [],
|
||||
topEndpoints: stats.topIPs.map(ip => ({
|
||||
endpoint: ip.ip,
|
||||
requests: ip.count,
|
||||
bandwidth: {
|
||||
in: 0,
|
||||
out: 0,
|
||||
},
|
||||
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
|
||||
})),
|
||||
throughputHistory: stats.throughputHistory || [],
|
||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||
requestsTotal: stats.requestsTotal || 0,
|
||||
};
|
||||
})
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,6 +335,7 @@ export class StatsHandler {
|
||||
requestsPerSecond: number;
|
||||
activeConnections: number;
|
||||
totalConnections: number;
|
||||
throughput: interfaces.data.IServerStats['throughput'];
|
||||
history: Array<{
|
||||
timestamp: number;
|
||||
value: number;
|
||||
@@ -316,6 +351,7 @@ export class StatsHandler {
|
||||
requestsPerSecond: serverStats.requestsPerSecond,
|
||||
activeConnections: serverStats.activeConnections,
|
||||
totalConnections: serverStats.totalConnections,
|
||||
throughput: serverStats.throughput,
|
||||
history: [], // TODO: Implement history tracking
|
||||
};
|
||||
}
|
||||
@@ -340,6 +376,7 @@ export class StatsHandler {
|
||||
requestsPerSecond: 0,
|
||||
activeConnections: 0,
|
||||
totalConnections: 0,
|
||||
throughput: { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
@@ -385,6 +422,7 @@ export class StatsHandler {
|
||||
count: number;
|
||||
}>;
|
||||
queryTypes: { [key: string]: number };
|
||||
recentQueries?: Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>;
|
||||
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
@@ -398,6 +436,7 @@ export class StatsHandler {
|
||||
cacheHitRate: dnsStats.cacheHitRate,
|
||||
topDomains: dnsStats.topDomains,
|
||||
queryTypes: dnsStats.queryTypes,
|
||||
recentQueries: dnsStats.recentQueries,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ export async function passGuards<T extends { identity?: any }>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check admin identity in handlers
|
||||
* Helper to check admin identity in handlers and middleware.
|
||||
* Accepts both optional and required identity for flexibility.
|
||||
*/
|
||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
export async function requireAdminIdentity(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T
|
||||
dataArg: { identity?: interfaces.data.IIdentity }
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
@@ -39,11 +40,12 @@ export async function requireAdminIdentity<T extends { identity?: interfaces.dat
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check valid identity in handlers
|
||||
* Helper to check valid identity in handlers and middleware.
|
||||
* Accepts both optional and required identity for flexibility.
|
||||
*/
|
||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
export async function requireValidIdentity(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T
|
||||
dataArg: { identity?: interfaces.data.IIdentity }
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
|
||||
75
ts/paths.ts
75
ts/paths.ts
@@ -1,48 +1,55 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
// Base directories
|
||||
export const baseDir = process.cwd();
|
||||
// Code/asset paths (not affected by baseDir)
|
||||
export const packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
);
|
||||
export const distServe = plugins.path.join(packageDir, './dist_serve');
|
||||
|
||||
// Configure data directory with environment variable or default to .nogit/data
|
||||
const DEFAULT_DATA_PATH = '.nogit/data';
|
||||
// Default base for all dcrouter data (always user-writable)
|
||||
export const dcrouterHomeDir = plugins.path.join(plugins.os.homedir(), '.serve.zone', 'dcrouter');
|
||||
|
||||
// Configure data directory with environment variable or default to ~/.serve.zone/dcrouter/data
|
||||
const DEFAULT_DATA_PATH = plugins.path.join(dcrouterHomeDir, 'data');
|
||||
export const dataDir = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: plugins.path.join(baseDir, DEFAULT_DATA_PATH);
|
||||
: DEFAULT_DATA_PATH;
|
||||
|
||||
// MTA directories
|
||||
export const keysDir = plugins.path.join(dataDir, 'keys');
|
||||
// Default TsmDB path for CacheDb
|
||||
export const defaultTsmDbPath = plugins.path.join(dcrouterHomeDir, 'tsmdb');
|
||||
|
||||
// DNS records directory (only surviving MTA directory reference)
|
||||
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
|
||||
export const sentEmailsDir = plugins.path.join(dataDir, 'emails', 'sent');
|
||||
export const receivedEmailsDir = plugins.path.join(dataDir, 'emails', 'received');
|
||||
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
|
||||
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
|
||||
|
||||
// Email template directories
|
||||
export const emailTemplatesDir = plugins.path.join(dataDir, 'templates', 'email');
|
||||
export const MtaAttachmentsDir = plugins.path.join(dataDir, 'attachments'); // For email attachments
|
||||
|
||||
// Configuration path
|
||||
export const configPath = process.env.CONFIG_PATH
|
||||
? process.env.CONFIG_PATH
|
||||
: plugins.path.join(baseDir, 'config.json');
|
||||
|
||||
// Create directories if they don't exist
|
||||
export function ensureDirectories() {
|
||||
// Ensure data directories
|
||||
plugins.fsUtils.ensureDirSync(dataDir);
|
||||
plugins.fsUtils.ensureDirSync(keysDir);
|
||||
plugins.fsUtils.ensureDirSync(dnsRecordsDir);
|
||||
plugins.fsUtils.ensureDirSync(sentEmailsDir);
|
||||
plugins.fsUtils.ensureDirSync(receivedEmailsDir);
|
||||
plugins.fsUtils.ensureDirSync(failedEmailsDir);
|
||||
plugins.fsUtils.ensureDirSync(logsDir);
|
||||
|
||||
// Ensure email template directories
|
||||
plugins.fsUtils.ensureDirSync(emailTemplatesDir);
|
||||
plugins.fsUtils.ensureDirSync(MtaAttachmentsDir);
|
||||
/**
|
||||
* Resolve all data paths from a given baseDir.
|
||||
* When no baseDir is provided, falls back to ~/.serve.zone/dcrouter.
|
||||
* Specific overrides (e.g. DATA_DIR env) take precedence.
|
||||
*/
|
||||
export function resolvePaths(baseDir?: string) {
|
||||
const root = baseDir ?? plugins.path.join(plugins.os.homedir(), '.serve.zone', 'dcrouter');
|
||||
const resolvedDataDir = process.env.DATA_DIR ?? plugins.path.join(root, 'data');
|
||||
return {
|
||||
dcrouterHomeDir: root,
|
||||
dataDir: resolvedDataDir,
|
||||
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
|
||||
defaultStoragePath: plugins.path.join(root, 'storage'),
|
||||
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure only the data directories that are actually used exist.
|
||||
*/
|
||||
export function ensureDataDirectories(resolvedPaths: ReturnType<typeof resolvePaths>) {
|
||||
plugins.fsUtils.ensureDirSync(resolvedPaths.dataDir);
|
||||
plugins.fsUtils.ensureDirSync(resolvedPaths.dnsRecordsDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy wrapper — delegates to ensureDataDirectories with module-level defaults.
|
||||
*/
|
||||
export function ensureDirectories() {
|
||||
ensureDataDirectories(resolvePaths());
|
||||
}
|
||||
@@ -23,9 +23,11 @@ export {
|
||||
|
||||
// @serve.zone scope
|
||||
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
||||
import * as remoteingress from '@serve.zone/remoteingress';
|
||||
|
||||
export {
|
||||
servezoneInterfaces
|
||||
servezoneInterfaces,
|
||||
remoteingress,
|
||||
}
|
||||
|
||||
// @api.global scope
|
||||
|
||||
@@ -100,6 +100,14 @@ export class VlanManager {
|
||||
// Cache the result
|
||||
this.normalizedMacCache.set(mac, normalized);
|
||||
|
||||
// Prevent unbounded cache growth
|
||||
if (this.normalizedMacCache.size > 10000) {
|
||||
const iterator = this.normalizedMacCache.keys();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
this.normalizedMacCache.delete(iterator.next().value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
146
ts/readme.md
Normal file
146
ts/readme.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# @serve.zone/dcrouter
|
||||
|
||||
The core DcRouter package — a unified datacenter gateway orchestrator. 🚀
|
||||
|
||||
This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-app',
|
||||
match: { domains: ['example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.10', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
}
|
||||
],
|
||||
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
|
||||
}
|
||||
});
|
||||
|
||||
await router.start();
|
||||
// OpsServer dashboard at http://localhost:3000
|
||||
|
||||
// Graceful shutdown
|
||||
await router.stop();
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
ts/
|
||||
├── index.ts # Main exports (DcRouter, re-exported smartmta types)
|
||||
├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions
|
||||
├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler
|
||||
├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager
|
||||
├── logger.ts # Structured logging utility
|
||||
├── paths.ts # Centralized data directory paths
|
||||
├── plugins.ts # All dependency imports
|
||||
├── cache/ # Cache database (smartdata + LocalTsmDb)
|
||||
│ ├── classes.cachedb.ts # CacheDb singleton
|
||||
│ ├── classes.cachecleaner.ts # TTL-based cleanup
|
||||
│ └── documents/ # Cached document models
|
||||
├── config/ # Configuration utilities
|
||||
├── errors/ # Error classes and retry logic
|
||||
├── monitoring/ # MetricsManager (SmartMetrics integration)
|
||||
├── opsserver/ # OpsServer dashboard + API handlers
|
||||
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
|
||||
│ └── handlers/ # TypedRequest handlers by domain
|
||||
│ ├── admin.handler.ts # Auth (login/logout/verify)
|
||||
│ ├── stats.handler.ts # Statistics + health
|
||||
│ ├── config.handler.ts # Configuration (read-only)
|
||||
│ ├── logs.handler.ts # Log retrieval
|
||||
│ ├── email.handler.ts # Email operations
|
||||
│ ├── certificate.handler.ts # Certificate management
|
||||
│ ├── radius.handler.ts # RADIUS management
|
||||
│ └── remoteingress.handler.ts # Remote ingress edge + token management
|
||||
├── radius/ # RADIUS server integration
|
||||
├── remoteingress/ # Remote ingress hub integration
|
||||
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
|
||||
│ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
|
||||
├── security/ # Security utilities
|
||||
├── sms/ # SMS integration
|
||||
└── storage/ # StorageManager (filesystem/custom/memory)
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
```typescript
|
||||
// Main class
|
||||
export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js';
|
||||
|
||||
// Re-exported from smartmta
|
||||
export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
|
||||
// RADIUS
|
||||
export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
|
||||
|
||||
// Remote Ingress
|
||||
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
```
|
||||
|
||||
## Key Classes
|
||||
|
||||
### `DcRouter`
|
||||
|
||||
The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
|
||||
|
||||
| Config Section | Service Started | Package |
|
||||
|----------------|----------------|---------|
|
||||
| `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
|
||||
| `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
|
||||
| `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
|
||||
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
|
||||
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
|
||||
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
|
||||
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
|
||||
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
|
||||
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
|
||||
|
||||
### `RemoteIngressManager`
|
||||
|
||||
Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
|
||||
|
||||
### `TunnelManager`
|
||||
|
||||
Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
|
||||
|
||||
## 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.
|
||||
|
||||
**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.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
258
ts/remoteingress/classes.remoteingress-manager.ts
Normal file
258
ts/remoteingress/classes.remoteingress-manager.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
||||
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const STORAGE_PREFIX = '/remote-ingress/';
|
||||
|
||||
/**
|
||||
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
||||
*/
|
||||
function extractPorts(portRange: number | number[] | Array<{ from: number; to: number }>): number[] {
|
||||
const ports = new Set<number>();
|
||||
if (typeof portRange === 'number') {
|
||||
ports.add(portRange);
|
||||
} else if (Array.isArray(portRange)) {
|
||||
for (const entry of portRange) {
|
||||
if (typeof entry === 'number') {
|
||||
ports.add(entry);
|
||||
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
|
||||
for (let p = entry.from; p <= entry.to; p++) {
|
||||
ports.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...ports].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages CRUD for remote ingress edge registrations.
|
||||
* Persists edge configs via StorageManager and provides
|
||||
* the allowed edges list for the Rust hub.
|
||||
*/
|
||||
export class RemoteIngressManager {
|
||||
private storageManager: StorageManager;
|
||||
private edges: Map<string, IRemoteIngress> = new Map();
|
||||
private routes: IDcRouterRouteConfig[] = [];
|
||||
|
||||
constructor(storageManager: StorageManager) {
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all edge registrations from storage into memory.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
const keys = await this.storageManager.list(STORAGE_PREFIX);
|
||||
for (const key of keys) {
|
||||
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
|
||||
if (edge) {
|
||||
// Migration: old edges without autoDerivePorts default to true
|
||||
if ((edge as any).autoDerivePorts === undefined) {
|
||||
edge.autoDerivePorts = true;
|
||||
await this.storageManager.setJSON(key, edge);
|
||||
}
|
||||
this.edges.set(edge.id, edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the current route configs for port derivation.
|
||||
*/
|
||||
public setRoutes(routes: IDcRouterRouteConfig[]): void {
|
||||
this.routes = routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
|
||||
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
|
||||
* When edgeFilter is absent, the route applies to all edges.
|
||||
*/
|
||||
public derivePortsForEdge(edgeId: string, edgeTags?: string[]): number[] {
|
||||
const ports = new Set<number>();
|
||||
|
||||
for (const route of this.routes) {
|
||||
if (!route.remoteIngress?.enabled) continue;
|
||||
|
||||
// Apply edge filter if present
|
||||
const filter = route.remoteIngress.edgeFilter;
|
||||
if (filter && filter.length > 0) {
|
||||
const idMatch = filter.includes(edgeId);
|
||||
const tagMatch = edgeTags?.some((tag) => filter.includes(tag)) ?? false;
|
||||
if (!idMatch && !tagMatch) continue;
|
||||
}
|
||||
|
||||
// Extract ports from the route match
|
||||
if (route.match?.ports) {
|
||||
for (const p of extractPorts(route.match.ports)) {
|
||||
ports.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...ports].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective listen ports for an edge.
|
||||
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
|
||||
*/
|
||||
public getEffectiveListenPorts(edge: IRemoteIngress): number[] {
|
||||
const manualPorts = edge.listenPorts || [];
|
||||
const shouldDerive = edge.autoDerivePorts !== false;
|
||||
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
|
||||
const derivedPorts = this.derivePortsForEdge(edge.id, edge.tags);
|
||||
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manual and derived port breakdown for an edge (used in API responses).
|
||||
* Derived ports exclude any ports already present in the manual list.
|
||||
*/
|
||||
public getPortBreakdown(edge: IRemoteIngress): { manual: number[]; derived: number[] } {
|
||||
const manual = edge.listenPorts || [];
|
||||
const shouldDerive = edge.autoDerivePorts !== false;
|
||||
if (!shouldDerive) return { manual, derived: [] };
|
||||
const manualSet = new Set(manual);
|
||||
const allDerived = this.derivePortsForEdge(edge.id, edge.tags);
|
||||
const derived = allDerived.filter((p) => !manualSet.has(p));
|
||||
return { manual, derived };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new edge registration.
|
||||
*/
|
||||
public async createEdge(
|
||||
name: string,
|
||||
listenPorts: number[] = [],
|
||||
tags?: string[],
|
||||
autoDerivePorts: boolean = true,
|
||||
): Promise<IRemoteIngress> {
|
||||
const id = plugins.uuid.v4();
|
||||
const secret = plugins.crypto.randomBytes(32).toString('hex');
|
||||
const now = Date.now();
|
||||
|
||||
const edge: IRemoteIngress = {
|
||||
id,
|
||||
name,
|
||||
secret,
|
||||
listenPorts,
|
||||
enabled: true,
|
||||
autoDerivePorts,
|
||||
tags: tags || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
||||
this.edges.set(id, edge);
|
||||
return edge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an edge by ID.
|
||||
*/
|
||||
public getEdge(id: string): IRemoteIngress | undefined {
|
||||
return this.edges.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all edge registrations.
|
||||
*/
|
||||
public getAllEdges(): IRemoteIngress[] {
|
||||
return Array.from(this.edges.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an edge registration.
|
||||
*/
|
||||
public async updateEdge(
|
||||
id: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
enabled?: boolean;
|
||||
tags?: string[];
|
||||
},
|
||||
): Promise<IRemoteIngress | null> {
|
||||
const edge = this.edges.get(id);
|
||||
if (!edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (updates.name !== undefined) edge.name = updates.name;
|
||||
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
|
||||
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
|
||||
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
|
||||
if (updates.tags !== undefined) edge.tags = updates.tags;
|
||||
edge.updatedAt = Date.now();
|
||||
|
||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
||||
this.edges.set(id, edge);
|
||||
return edge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an edge registration.
|
||||
*/
|
||||
public async deleteEdge(id: string): Promise<boolean> {
|
||||
if (!this.edges.has(id)) {
|
||||
return false;
|
||||
}
|
||||
await this.storageManager.delete(`${STORAGE_PREFIX}${id}`);
|
||||
this.edges.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the secret for an edge.
|
||||
*/
|
||||
public async regenerateSecret(id: string): Promise<string | null> {
|
||||
const edge = this.edges.get(id);
|
||||
if (!edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
|
||||
edge.updatedAt = Date.now();
|
||||
|
||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
||||
this.edges.set(id, edge);
|
||||
return edge.secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an edge's secret using constant-time comparison.
|
||||
*/
|
||||
public verifySecret(id: string, secret: string): boolean {
|
||||
const edge = this.edges.get(id);
|
||||
if (!edge) {
|
||||
return false;
|
||||
}
|
||||
const expected = Buffer.from(edge.secret);
|
||||
const provided = Buffer.from(secret);
|
||||
if (expected.length !== provided.length) {
|
||||
return false;
|
||||
}
|
||||
return plugins.crypto.timingSafeEqual(expected, provided);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of allowed edges (enabled only) for the Rust hub.
|
||||
*/
|
||||
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[] }> {
|
||||
const result: Array<{ id: string; secret: string; listenPorts: number[] }> = [];
|
||||
for (const edge of this.edges.values()) {
|
||||
if (edge.enabled) {
|
||||
result.push({
|
||||
id: edge.id,
|
||||
secret: edge.secret,
|
||||
listenPorts: this.getEffectiveListenPorts(edge),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
192
ts/remoteingress/classes.tunnel-manager.ts
Normal file
192
ts/remoteingress/classes.tunnel-manager.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
|
||||
|
||||
export interface ITunnelManagerConfig {
|
||||
tunnelPort?: number;
|
||||
targetHost?: string;
|
||||
tls?: {
|
||||
certPem?: string;
|
||||
keyPem?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the RemoteIngressHub instance and tracks connected edge statuses.
|
||||
*/
|
||||
export class TunnelManager {
|
||||
private hub: InstanceType<typeof plugins.remoteingress.RemoteIngressHub>;
|
||||
private manager: RemoteIngressManager;
|
||||
private config: ITunnelManagerConfig;
|
||||
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
|
||||
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
|
||||
this.manager = manager;
|
||||
this.config = config;
|
||||
this.hub = new plugins.remoteingress.RemoteIngressHub();
|
||||
|
||||
// Listen for edge connect/disconnect events
|
||||
this.hub.on('edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
|
||||
this.edgeStatuses.set(data.edgeId, {
|
||||
edgeId: data.edgeId,
|
||||
connected: true,
|
||||
publicIp: data.peerAddr || null,
|
||||
activeTunnels: 0,
|
||||
lastHeartbeat: Date.now(),
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
this.hub.on('edgeDisconnected', (data: { edgeId: string }) => {
|
||||
this.edgeStatuses.delete(data.edgeId);
|
||||
});
|
||||
|
||||
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
|
||||
const existing = this.edgeStatuses.get(data.edgeId);
|
||||
if (existing) {
|
||||
existing.activeTunnels++;
|
||||
existing.lastHeartbeat = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => {
|
||||
const existing = this.edgeStatuses.get(data.edgeId);
|
||||
if (existing && existing.activeTunnels > 0) {
|
||||
existing.activeTunnels--;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the tunnel hub and load allowed edges.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
await this.hub.start({
|
||||
tunnelPort: this.config.tunnelPort ?? 8443,
|
||||
targetHost: this.config.targetHost ?? '127.0.0.1',
|
||||
tls: this.config.tls,
|
||||
});
|
||||
|
||||
// Send allowed edges to the hub
|
||||
await this.syncAllowedEdges();
|
||||
|
||||
// Periodically reconcile with authoritative Rust hub status
|
||||
this.reconcileInterval = setInterval(() => {
|
||||
this.reconcile().catch(() => {});
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the tunnel hub.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.reconcileInterval) {
|
||||
clearInterval(this.reconcileInterval);
|
||||
this.reconcileInterval = null;
|
||||
}
|
||||
// Remove event listeners before stopping to prevent leaks
|
||||
this.hub.removeAllListeners();
|
||||
await this.hub.stop();
|
||||
this.edgeStatuses.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile TS-side edge statuses with the authoritative Rust hub status.
|
||||
* Overwrites event-derived activeTunnels with the real activeStreams count.
|
||||
*/
|
||||
private async reconcile(): Promise<void> {
|
||||
const hubStatus = await this.hub.getStatus();
|
||||
if (!hubStatus || !hubStatus.connectedEdges) return;
|
||||
|
||||
const rustEdgeIds = new Set<string>();
|
||||
|
||||
for (const rustEdge of hubStatus.connectedEdges) {
|
||||
rustEdgeIds.add(rustEdge.edgeId);
|
||||
const existing = this.edgeStatuses.get(rustEdge.edgeId);
|
||||
if (existing) {
|
||||
existing.activeTunnels = rustEdge.activeStreams;
|
||||
existing.lastHeartbeat = Date.now();
|
||||
// Update peer address if available from Rust hub
|
||||
if (rustEdge.peerAddr) {
|
||||
existing.publicIp = rustEdge.peerAddr;
|
||||
}
|
||||
} else {
|
||||
// Missed edgeConnected event — add entry
|
||||
this.edgeStatuses.set(rustEdge.edgeId, {
|
||||
edgeId: rustEdge.edgeId,
|
||||
connected: true,
|
||||
publicIp: rustEdge.peerAddr || null,
|
||||
activeTunnels: rustEdge.activeStreams,
|
||||
lastHeartbeat: Date.now(),
|
||||
connectedAt: rustEdge.connectedAt * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove entries for edges no longer connected in Rust (missed edgeDisconnected)
|
||||
for (const edgeId of this.edgeStatuses.keys()) {
|
||||
if (!rustEdgeIds.has(edgeId)) {
|
||||
this.edgeStatuses.delete(edgeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync allowed edges from the manager to the hub.
|
||||
* Call this after creating/deleting/updating edges.
|
||||
*/
|
||||
public async syncAllowedEdges(): Promise<void> {
|
||||
const edges = this.manager.getAllowedEdges();
|
||||
await this.hub.updateAllowedEdges(edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runtime statuses for all known edges.
|
||||
*/
|
||||
public getEdgeStatuses(): IRemoteIngressStatus[] {
|
||||
return Array.from(this.edgeStatuses.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for a specific edge.
|
||||
*/
|
||||
public getEdgeStatus(edgeId: string): IRemoteIngressStatus | undefined {
|
||||
return this.edgeStatuses.get(edgeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of connected edges.
|
||||
*/
|
||||
public getConnectedCount(): number {
|
||||
let count = 0;
|
||||
for (const status of this.edgeStatuses.values()) {
|
||||
if (status.connected) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public IPs of all connected edges.
|
||||
*/
|
||||
public getConnectedEdgeIps(): string[] {
|
||||
const ips: string[] = [];
|
||||
for (const status of this.edgeStatuses.values()) {
|
||||
if (status.connected && status.publicIp) {
|
||||
ips.push(status.publicIp);
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of active tunnels across all edges.
|
||||
*/
|
||||
public getTotalActiveTunnels(): number {
|
||||
let total = 0;
|
||||
for (const status of this.edgeStatuses.values()) {
|
||||
total += status.activeTunnels;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
2
ts/remoteingress/index.ts
Normal file
2
ts/remoteingress/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './classes.remoteingress-manager.js';
|
||||
export * from './classes.tunnel-manager.js';
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { Email, type Core } from '@push.rocks/smartmta';
|
||||
type IAttachment = Core.IAttachment;
|
||||
@@ -184,6 +183,13 @@ export class ContentScanner {
|
||||
return ContentScanner.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for shutdown/testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
ContentScanner.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan an email for malicious content
|
||||
* @param email The email to scan
|
||||
|
||||
@@ -65,6 +65,8 @@ export class IPReputationChecker {
|
||||
private reputationCache: LRUCache<string, IReputationResult>;
|
||||
private options: Required<IIPReputationOptions>;
|
||||
private storageManager?: any; // StorageManager instance
|
||||
private saveCacheTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private static readonly SAVE_CACHE_DEBOUNCE_MS = 30_000;
|
||||
|
||||
// Default DNSBL servers
|
||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||||
@@ -144,6 +146,19 @@ export class IPReputationChecker {
|
||||
return IPReputationChecker.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for shutdown/testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
if (IPReputationChecker.instance) {
|
||||
if (IPReputationChecker.instance.saveCacheTimer) {
|
||||
clearTimeout(IPReputationChecker.instance.saveCacheTimer);
|
||||
IPReputationChecker.instance.saveCacheTimer = null;
|
||||
}
|
||||
}
|
||||
IPReputationChecker.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an IP address's reputation
|
||||
* @param ip IP address to check
|
||||
@@ -213,12 +228,9 @@ export class IPReputationChecker {
|
||||
// Update cache with result
|
||||
this.reputationCache.set(ip, result);
|
||||
|
||||
// Save cache if enabled
|
||||
// Schedule debounced cache save if enabled
|
||||
if (this.options.enableLocalCache) {
|
||||
// Fire and forget the save operation
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
});
|
||||
this.debouncedSaveCache();
|
||||
}
|
||||
|
||||
// Log the reputation check
|
||||
@@ -447,6 +459,21 @@ export class IPReputationChecker {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced cache save (at most once per SAVE_CACHE_DEBOUNCE_MS)
|
||||
*/
|
||||
private debouncedSaveCache(): void {
|
||||
if (this.saveCacheTimer) {
|
||||
return; // already scheduled
|
||||
}
|
||||
this.saveCacheTimer = setTimeout(() => {
|
||||
this.saveCacheTimer = null;
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
});
|
||||
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to disk or storage manager
|
||||
*/
|
||||
|
||||
@@ -84,6 +84,13 @@ export class SecurityLogger {
|
||||
return SecurityLogger.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for shutdown/testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
SecurityLogger.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
* @param event The security event to log
|
||||
@@ -155,8 +162,9 @@ export class SecurityLogger {
|
||||
}
|
||||
}
|
||||
|
||||
// Return most recent events up to limit
|
||||
// Return most recent events up to limit (slice first to avoid mutating source)
|
||||
return filteredEvents
|
||||
.slice()
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -242,40 +250,34 @@ export class SecurityLogger {
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
topDomains: Array<{ domain: string; count: number }>;
|
||||
} {
|
||||
// Filter by time window if provided
|
||||
let events = this.securityEvents;
|
||||
if (timeWindow) {
|
||||
const cutoff = Date.now() - timeWindow;
|
||||
events = events.filter(e => e.timestamp >= cutoff);
|
||||
const cutoff = timeWindow ? Date.now() - timeWindow : 0;
|
||||
|
||||
// Initialize counters
|
||||
const byLevel = {} as Record<SecurityLogLevel, number>;
|
||||
for (const level of Object.values(SecurityLogLevel)) {
|
||||
byLevel[level] = 0;
|
||||
}
|
||||
const byType = {} as Record<SecurityEventType, number>;
|
||||
for (const type of Object.values(SecurityEventType)) {
|
||||
byType[type] = 0;
|
||||
}
|
||||
|
||||
// Count by level
|
||||
const byLevel = Object.values(SecurityLogLevel).reduce((acc, level) => {
|
||||
acc[level] = events.filter(e => e.level === level).length;
|
||||
return acc;
|
||||
}, {} as Record<SecurityLogLevel, number>);
|
||||
|
||||
// Count by type
|
||||
const byType = Object.values(SecurityEventType).reduce((acc, type) => {
|
||||
acc[type] = events.filter(e => e.type === type).length;
|
||||
return acc;
|
||||
}, {} as Record<SecurityEventType, number>);
|
||||
|
||||
// Count by IP
|
||||
const ipCounts = new Map<string, number>();
|
||||
events.forEach(e => {
|
||||
const domainCounts = new Map<string, number>();
|
||||
|
||||
// Single pass over all events
|
||||
let total = 0;
|
||||
for (const e of this.securityEvents) {
|
||||
if (cutoff && e.timestamp < cutoff) continue;
|
||||
total++;
|
||||
byLevel[e.level]++;
|
||||
byType[e.type]++;
|
||||
if (e.ipAddress) {
|
||||
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Count by domain
|
||||
const domainCounts = new Map<string, number>();
|
||||
events.forEach(e => {
|
||||
if (e.domain) {
|
||||
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort and limit top entries
|
||||
const topIPs = Array.from(ipCounts.entries())
|
||||
@@ -288,12 +290,6 @@ export class SecurityLogger {
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
total: events.length,
|
||||
byLevel,
|
||||
byType,
|
||||
topIPs,
|
||||
topDomains
|
||||
};
|
||||
return { total, byLevel, byType, topIPs, topDomains };
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export type StorageBackend = 'filesystem' | 'custom' | 'memory';
|
||||
* Provides unified key-value storage with multiple backend support
|
||||
*/
|
||||
export class StorageManager {
|
||||
private static readonly MAX_MEMORY_ENTRIES = 10_000;
|
||||
private backend: StorageBackend;
|
||||
private memoryStore: Map<string, string> = new Map();
|
||||
private config: IStorageConfig;
|
||||
@@ -227,6 +228,11 @@ export class StorageManager {
|
||||
|
||||
case 'memory': {
|
||||
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;
|
||||
this.memoryStore.delete(firstKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -378,7 +384,7 @@ export class StorageManager {
|
||||
*/
|
||||
async getJSON<T = any>(key: string): Promise<T | null> {
|
||||
const value = await this.get(key);
|
||||
if (value === null) {
|
||||
if (value === null || value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
157
ts_apiclient/classes.apitoken.ts
Normal file
157
ts_apiclient/classes.apitoken.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class ApiToken {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
// Data from IApiTokenInfo
|
||||
public id: string;
|
||||
public name: string;
|
||||
public scopes: interfaces.data.TApiTokenScope[];
|
||||
public createdAt: number;
|
||||
public expiresAt: number | null;
|
||||
public lastUsedAt: number | null;
|
||||
public enabled: boolean;
|
||||
|
||||
/** Only set on creation or roll. Not persisted on server side. */
|
||||
public tokenValue?: string;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IApiTokenInfo, tokenValue?: string) {
|
||||
this.clientRef = clientRef;
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.scopes = data.scopes;
|
||||
this.createdAt = data.createdAt;
|
||||
this.expiresAt = data.expiresAt;
|
||||
this.lastUsedAt = data.lastUsedAt;
|
||||
this.enabled = data.enabled;
|
||||
this.tokenValue = tokenValue;
|
||||
}
|
||||
|
||||
public async revoke(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RevokeApiToken>(
|
||||
'revokeApiToken',
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to revoke token');
|
||||
}
|
||||
}
|
||||
|
||||
public async roll(): Promise<string> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RollApiToken>(
|
||||
'rollApiToken',
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to roll token');
|
||||
}
|
||||
this.tokenValue = response.tokenValue;
|
||||
return response.tokenValue!;
|
||||
}
|
||||
|
||||
public async toggle(enabled: boolean): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleApiToken>(
|
||||
'toggleApiToken',
|
||||
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to toggle token');
|
||||
}
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiTokenBuilder {
|
||||
private clientRef: DcRouterApiClient;
|
||||
private tokenName: string = '';
|
||||
private tokenScopes: interfaces.data.TApiTokenScope[] = [];
|
||||
private tokenExpiresInDays?: number | null;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public setName(name: string): this {
|
||||
this.tokenName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setScopes(scopes: interfaces.data.TApiTokenScope[]): this {
|
||||
this.tokenScopes = scopes;
|
||||
return this;
|
||||
}
|
||||
|
||||
public addScope(scope: interfaces.data.TApiTokenScope): this {
|
||||
if (!this.tokenScopes.includes(scope)) {
|
||||
this.tokenScopes.push(scope);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public setExpiresInDays(days: number | null): this {
|
||||
this.tokenExpiresInDays = days;
|
||||
return this;
|
||||
}
|
||||
|
||||
public async save(): Promise<ApiToken> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_CreateApiToken>(
|
||||
'createApiToken',
|
||||
this.clientRef.buildRequestPayload({
|
||||
name: this.tokenName,
|
||||
scopes: this.tokenScopes,
|
||||
expiresInDays: this.tokenExpiresInDays,
|
||||
}) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to create API token');
|
||||
}
|
||||
return new ApiToken(
|
||||
this.clientRef,
|
||||
{
|
||||
id: response.tokenId!,
|
||||
name: this.tokenName,
|
||||
scopes: this.tokenScopes,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: this.tokenExpiresInDays
|
||||
? Date.now() + this.tokenExpiresInDays * 24 * 60 * 60 * 1000
|
||||
: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
},
|
||||
response.tokenValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiTokenManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<ApiToken[]> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ListApiTokens>(
|
||||
'listApiTokens',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.tokens.map((t) => new ApiToken(this.clientRef, t));
|
||||
}
|
||||
|
||||
public async create(options: {
|
||||
name: string;
|
||||
scopes: interfaces.data.TApiTokenScope[];
|
||||
expiresInDays?: number | null;
|
||||
}): Promise<ApiToken> {
|
||||
return this.build()
|
||||
.setName(options.name)
|
||||
.setScopes(options.scopes)
|
||||
.setExpiresInDays(options.expiresInDays ?? null)
|
||||
.save();
|
||||
}
|
||||
|
||||
public build(): ApiTokenBuilder {
|
||||
return new ApiTokenBuilder(this.clientRef);
|
||||
}
|
||||
}
|
||||
123
ts_apiclient/classes.certificate.ts
Normal file
123
ts_apiclient/classes.certificate.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class Certificate {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
// Data from ICertificateInfo
|
||||
public domain: string;
|
||||
public routeNames: string[];
|
||||
public status: interfaces.requests.TCertificateStatus;
|
||||
public source: interfaces.requests.TCertificateSource;
|
||||
public tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||
public expiryDate?: string;
|
||||
public issuer?: string;
|
||||
public issuedAt?: string;
|
||||
public error?: string;
|
||||
public canReprovision: boolean;
|
||||
public backoffInfo?: {
|
||||
failures: number;
|
||||
retryAfter?: string;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.requests.ICertificateInfo) {
|
||||
this.clientRef = clientRef;
|
||||
this.domain = data.domain;
|
||||
this.routeNames = data.routeNames;
|
||||
this.status = data.status;
|
||||
this.source = data.source;
|
||||
this.tlsMode = data.tlsMode;
|
||||
this.expiryDate = data.expiryDate;
|
||||
this.issuer = data.issuer;
|
||||
this.issuedAt = data.issuedAt;
|
||||
this.error = data.error;
|
||||
this.canReprovision = data.canReprovision;
|
||||
this.backoffInfo = data.backoffInfo;
|
||||
}
|
||||
|
||||
public async reprovision(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
'reprovisionCertificateDomain',
|
||||
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to reprovision certificate');
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteCertificate>(
|
||||
'deleteCertificate',
|
||||
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete certificate');
|
||||
}
|
||||
}
|
||||
|
||||
public async export(): Promise<{
|
||||
id: string;
|
||||
domainName: string;
|
||||
created: number;
|
||||
validUntil: number;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
csr: string;
|
||||
} | undefined> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ExportCertificate>(
|
||||
'exportCertificate',
|
||||
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to export certificate');
|
||||
}
|
||||
return response.cert;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ICertificateSummary {
|
||||
total: number;
|
||||
valid: number;
|
||||
expiring: number;
|
||||
expired: number;
|
||||
failed: number;
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export class CertificateManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<{ certificates: Certificate[]; summary: ICertificateSummary }> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetCertificateOverview>(
|
||||
'getCertificateOverview',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return {
|
||||
certificates: response.certificates.map((c) => new Certificate(this.clientRef, c)),
|
||||
summary: response.summary,
|
||||
};
|
||||
}
|
||||
|
||||
public async import(cert: {
|
||||
id: string;
|
||||
domainName: string;
|
||||
created: number;
|
||||
validUntil: number;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
csr: string;
|
||||
}): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ImportCertificate>(
|
||||
'importCertificate',
|
||||
this.clientRef.buildRequestPayload({ cert }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to import certificate');
|
||||
}
|
||||
}
|
||||
}
|
||||
17
ts_apiclient/classes.config.ts
Normal file
17
ts_apiclient/classes.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class ConfigManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async get(section?: string): Promise<interfaces.requests.IReq_GetConfiguration['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
this.clientRef.buildRequestPayload({ section }) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
112
ts_apiclient/classes.dcrouterapiclient.ts
Normal file
112
ts_apiclient/classes.dcrouterapiclient.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
import { RouteManager } from './classes.route.js';
|
||||
import { CertificateManager } from './classes.certificate.js';
|
||||
import { ApiTokenManager } from './classes.apitoken.js';
|
||||
import { RemoteIngressManager } from './classes.remoteingress.js';
|
||||
import { StatsManager } from './classes.stats.js';
|
||||
import { ConfigManager } from './classes.config.js';
|
||||
import { LogManager } from './classes.logs.js';
|
||||
import { EmailManager } from './classes.email.js';
|
||||
import { RadiusManager } from './classes.radius.js';
|
||||
|
||||
export interface IDcRouterApiClientOptions {
|
||||
baseUrl: string;
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export class DcRouterApiClient {
|
||||
public baseUrl: string;
|
||||
public apiToken?: string;
|
||||
public identity?: interfaces.data.IIdentity;
|
||||
|
||||
// Resource managers
|
||||
public routes: RouteManager;
|
||||
public certificates: CertificateManager;
|
||||
public apiTokens: ApiTokenManager;
|
||||
public remoteIngress: RemoteIngressManager;
|
||||
public stats: StatsManager;
|
||||
public config: ConfigManager;
|
||||
public logs: LogManager;
|
||||
public emails: EmailManager;
|
||||
public radius: RadiusManager;
|
||||
|
||||
constructor(options: IDcRouterApiClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
this.apiToken = options.apiToken;
|
||||
|
||||
this.routes = new RouteManager(this);
|
||||
this.certificates = new CertificateManager(this);
|
||||
this.apiTokens = new ApiTokenManager(this);
|
||||
this.remoteIngress = new RemoteIngressManager(this);
|
||||
this.stats = new StatsManager(this);
|
||||
this.config = new ConfigManager(this);
|
||||
this.logs = new LogManager(this);
|
||||
this.emails = new EmailManager(this);
|
||||
this.radius = new RadiusManager(this);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Auth
|
||||
// =====================
|
||||
|
||||
public async login(username: string, password: string): Promise<interfaces.data.IIdentity> {
|
||||
const response = await this.request<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
{ username, password },
|
||||
);
|
||||
if (response.identity) {
|
||||
this.identity = response.identity;
|
||||
}
|
||||
return response.identity!;
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
await this.request<interfaces.requests.IReq_AdminLogout>(
|
||||
'adminLogout',
|
||||
{ identity: this.identity! },
|
||||
);
|
||||
this.identity = undefined;
|
||||
}
|
||||
|
||||
public async verifyIdentity(): Promise<{ valid: boolean; identity?: interfaces.data.IIdentity }> {
|
||||
const response = await this.request<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'verifyIdentity',
|
||||
{ identity: this.identity! },
|
||||
);
|
||||
if (response.identity) {
|
||||
this.identity = response.identity;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Internal request helper
|
||||
// =====================
|
||||
|
||||
public async request<T extends plugins.typedrequestInterfaces.ITypedRequest>(
|
||||
method: string,
|
||||
requestData: T['request'],
|
||||
): Promise<T['response']> {
|
||||
const typedRequest = new plugins.typedrequest.TypedRequest<T>(
|
||||
`${this.baseUrl}/typedrequest`,
|
||||
method,
|
||||
);
|
||||
return typedRequest.fire(requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a request payload with identity and optional API token auto-injected.
|
||||
*/
|
||||
public buildRequestPayload(extra: Record<string, any> = {}): Record<string, any> {
|
||||
const payload: Record<string, any> = { ...extra };
|
||||
if (this.identity) {
|
||||
payload.identity = this.identity;
|
||||
}
|
||||
if (this.apiToken) {
|
||||
payload.apiToken = this.apiToken;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
77
ts_apiclient/classes.email.ts
Normal file
77
ts_apiclient/classes.email.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class Email {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
// Data from IEmail
|
||||
public id: string;
|
||||
public direction: interfaces.requests.TEmailDirection;
|
||||
public status: interfaces.requests.TEmailStatus;
|
||||
public from: string;
|
||||
public to: string;
|
||||
public subject: string;
|
||||
public timestamp: string;
|
||||
public messageId: string;
|
||||
public size: string;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.requests.IEmail) {
|
||||
this.clientRef = clientRef;
|
||||
this.id = data.id;
|
||||
this.direction = data.direction;
|
||||
this.status = data.status;
|
||||
this.from = data.from;
|
||||
this.to = data.to;
|
||||
this.subject = data.subject;
|
||||
this.timestamp = data.timestamp;
|
||||
this.messageId = data.messageId;
|
||||
this.size = data.size;
|
||||
}
|
||||
|
||||
public async getDetail(): Promise<interfaces.requests.IEmailDetail | null> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetEmailDetail>(
|
||||
'getEmailDetail',
|
||||
this.clientRef.buildRequestPayload({ emailId: this.id }) as any,
|
||||
);
|
||||
return response.email;
|
||||
}
|
||||
|
||||
public async resend(): Promise<{ success: boolean; newQueueId?: string }> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
this.clientRef.buildRequestPayload({ emailId: this.id }) as any,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<Email[]> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetAllEmails>(
|
||||
'getAllEmails',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.emails.map((e) => new Email(this.clientRef, e));
|
||||
}
|
||||
|
||||
public async getDetail(emailId: string): Promise<interfaces.requests.IEmailDetail | null> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetEmailDetail>(
|
||||
'getEmailDetail',
|
||||
this.clientRef.buildRequestPayload({ emailId }) as any,
|
||||
);
|
||||
return response.email;
|
||||
}
|
||||
|
||||
public async resend(emailId: string): Promise<{ success: boolean; newQueueId?: string }> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
this.clientRef.buildRequestPayload({ emailId }) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
ts_apiclient/classes.logs.ts
Normal file
37
ts_apiclient/classes.logs.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class LogManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async getRecent(options?: {
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
timeRange?: string;
|
||||
}): Promise<interfaces.requests.IReq_GetRecentLogs['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'getRecentLogs',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getStream(options?: {
|
||||
follow?: boolean;
|
||||
filters?: {
|
||||
level?: string[];
|
||||
category?: string[];
|
||||
};
|
||||
}): Promise<interfaces.requests.IReq_GetLogStream['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetLogStream>(
|
||||
'getLogStream',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
180
ts_apiclient/classes.radius.ts
Normal file
180
ts_apiclient/classes.radius.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
// =====================
|
||||
// Sub-managers
|
||||
// =====================
|
||||
|
||||
export class RadiusClientManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<Array<{
|
||||
name: string;
|
||||
ipRange: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}>> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRadiusClients>(
|
||||
'getRadiusClients',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.clients;
|
||||
}
|
||||
|
||||
public async set(client: {
|
||||
name: string;
|
||||
ipRange: string;
|
||||
secret: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetRadiusClient>(
|
||||
'setRadiusClient',
|
||||
this.clientRef.buildRequestPayload({ client }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to set RADIUS client');
|
||||
}
|
||||
}
|
||||
|
||||
public async remove(name: string): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||
'removeRadiusClient',
|
||||
this.clientRef.buildRequestPayload({ name }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to remove RADIUS client');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RadiusVlanManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<interfaces.requests.IReq_GetVlanMappings['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetVlanMappings>(
|
||||
'getVlanMappings',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async set(mapping: {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetVlanMapping>(
|
||||
'setVlanMapping',
|
||||
this.clientRef.buildRequestPayload({ mapping }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to set VLAN mapping');
|
||||
}
|
||||
}
|
||||
|
||||
public async remove(mac: string): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||
'removeVlanMapping',
|
||||
this.clientRef.buildRequestPayload({ mac }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to remove VLAN mapping');
|
||||
}
|
||||
}
|
||||
|
||||
public async updateConfig(options: {
|
||||
defaultVlan?: number;
|
||||
allowUnknownMacs?: boolean;
|
||||
}): Promise<{ defaultVlan: number; allowUnknownMacs: boolean }> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||
'updateVlanConfig',
|
||||
this.clientRef.buildRequestPayload(options) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to update VLAN config');
|
||||
}
|
||||
return response.config;
|
||||
}
|
||||
|
||||
public async testAssignment(mac: string): Promise<interfaces.requests.IReq_TestVlanAssignment['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_TestVlanAssignment>(
|
||||
'testVlanAssignment',
|
||||
this.clientRef.buildRequestPayload({ mac }) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class RadiusSessionManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(filter?: {
|
||||
username?: string;
|
||||
nasIpAddress?: string;
|
||||
vlanId?: number;
|
||||
}): Promise<interfaces.requests.IReq_GetRadiusSessions['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetRadiusSessions>(
|
||||
'getRadiusSessions',
|
||||
this.clientRef.buildRequestPayload({ filter }) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async disconnect(sessionId: string, reason?: string): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||
'disconnectRadiusSession',
|
||||
this.clientRef.buildRequestPayload({ sessionId, reason }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to disconnect session');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Main RADIUS Manager
|
||||
// =====================
|
||||
|
||||
export class RadiusManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
public clients: RadiusClientManager;
|
||||
public vlans: RadiusVlanManager;
|
||||
public sessions: RadiusSessionManager;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
this.clients = new RadiusClientManager(clientRef);
|
||||
this.vlans = new RadiusVlanManager(clientRef);
|
||||
this.sessions = new RadiusSessionManager(clientRef);
|
||||
}
|
||||
|
||||
public async getAccountingSummary(
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Promise<interfaces.requests.IReq_GetRadiusAccountingSummary['response']['summary']> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||
'getRadiusAccountingSummary',
|
||||
this.clientRef.buildRequestPayload({ startTime, endTime }) as any,
|
||||
);
|
||||
return response.summary;
|
||||
}
|
||||
|
||||
public async getStatistics(): Promise<interfaces.requests.IReq_GetRadiusStatistics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||
'getRadiusStatistics',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
185
ts_apiclient/classes.remoteingress.ts
Normal file
185
ts_apiclient/classes.remoteingress.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class RemoteIngress {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
// Data from IRemoteIngress
|
||||
public id: string;
|
||||
public name: string;
|
||||
public secret: string;
|
||||
public listenPorts: number[];
|
||||
public enabled: boolean;
|
||||
public autoDerivePorts: boolean;
|
||||
public tags?: string[];
|
||||
public createdAt: number;
|
||||
public updatedAt: number;
|
||||
public effectiveListenPorts?: number[];
|
||||
public manualPorts?: number[];
|
||||
public derivedPorts?: number[];
|
||||
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IRemoteIngress) {
|
||||
this.clientRef = clientRef;
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.secret = data.secret;
|
||||
this.listenPorts = data.listenPorts;
|
||||
this.enabled = data.enabled;
|
||||
this.autoDerivePorts = data.autoDerivePorts;
|
||||
this.tags = data.tags;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
this.effectiveListenPorts = data.effectiveListenPorts;
|
||||
this.manualPorts = data.manualPorts;
|
||||
this.derivedPorts = data.derivedPorts;
|
||||
}
|
||||
|
||||
public async update(changes: {
|
||||
name?: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
enabled?: boolean;
|
||||
tags?: string[];
|
||||
}): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRemoteIngress>(
|
||||
'updateRemoteIngress',
|
||||
this.clientRef.buildRequestPayload({ id: this.id, ...changes }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to update remote ingress');
|
||||
}
|
||||
// Update local state from response
|
||||
const edge = response.edge;
|
||||
this.name = edge.name;
|
||||
this.listenPorts = edge.listenPorts;
|
||||
this.enabled = edge.enabled;
|
||||
this.autoDerivePorts = edge.autoDerivePorts;
|
||||
this.tags = edge.tags;
|
||||
this.updatedAt = edge.updatedAt;
|
||||
this.effectiveListenPorts = edge.effectiveListenPorts;
|
||||
this.manualPorts = edge.manualPorts;
|
||||
this.derivedPorts = edge.derivedPorts;
|
||||
}
|
||||
|
||||
public async delete(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRemoteIngress>(
|
||||
'deleteRemoteIngress',
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete remote ingress');
|
||||
}
|
||||
}
|
||||
|
||||
public async regenerateSecret(): Promise<string> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
||||
'regenerateRemoteIngressSecret',
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to regenerate secret');
|
||||
}
|
||||
this.secret = response.secret;
|
||||
return response.secret;
|
||||
}
|
||||
|
||||
public async getConnectionToken(hubHost?: string): Promise<string | undefined> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
||||
'getRemoteIngressConnectionToken',
|
||||
this.clientRef.buildRequestPayload({ edgeId: this.id, hubHost }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to get connection token');
|
||||
}
|
||||
return response.token;
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteIngressBuilder {
|
||||
private clientRef: DcRouterApiClient;
|
||||
private edgeName: string = '';
|
||||
private edgeListenPorts?: number[];
|
||||
private edgeAutoDerivePorts?: boolean;
|
||||
private edgeTags?: string[];
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public setName(name: string): this {
|
||||
this.edgeName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setListenPorts(ports: number[]): this {
|
||||
this.edgeListenPorts = ports;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setAutoDerivePorts(auto: boolean): this {
|
||||
this.edgeAutoDerivePorts = auto;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setTags(tags: string[]): this {
|
||||
this.edgeTags = tags;
|
||||
return this;
|
||||
}
|
||||
|
||||
public async save(): Promise<RemoteIngress> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRemoteIngress>(
|
||||
'createRemoteIngress',
|
||||
this.clientRef.buildRequestPayload({
|
||||
name: this.edgeName,
|
||||
listenPorts: this.edgeListenPorts,
|
||||
autoDerivePorts: this.edgeAutoDerivePorts,
|
||||
tags: this.edgeTags,
|
||||
}) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to create remote ingress');
|
||||
}
|
||||
return new RemoteIngress(this.clientRef, response.edge);
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteIngressManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<RemoteIngress[]> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngresses>(
|
||||
'getRemoteIngresses',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.edges.map((e) => new RemoteIngress(this.clientRef, e));
|
||||
}
|
||||
|
||||
public async getStatuses(): Promise<interfaces.data.IRemoteIngressStatus[]> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
||||
'getRemoteIngressStatus',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.statuses;
|
||||
}
|
||||
|
||||
public async create(options: {
|
||||
name: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
tags?: string[];
|
||||
}): Promise<RemoteIngress> {
|
||||
const builder = this.build().setName(options.name);
|
||||
if (options.listenPorts) builder.setListenPorts(options.listenPorts);
|
||||
if (options.autoDerivePorts !== undefined) builder.setAutoDerivePorts(options.autoDerivePorts);
|
||||
if (options.tags) builder.setTags(options.tags);
|
||||
return builder.save();
|
||||
}
|
||||
|
||||
public build(): RemoteIngressBuilder {
|
||||
return new RemoteIngressBuilder(this.clientRef);
|
||||
}
|
||||
}
|
||||
203
ts_apiclient/classes.route.ts
Normal file
203
ts_apiclient/classes.route.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class Route {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
// Data from IMergedRoute
|
||||
public routeConfig: IRouteConfig;
|
||||
public source: 'hardcoded' | 'programmatic';
|
||||
public enabled: boolean;
|
||||
public overridden: boolean;
|
||||
public storedRouteId?: string;
|
||||
public createdAt?: number;
|
||||
public updatedAt?: number;
|
||||
|
||||
// Convenience accessors
|
||||
public get name(): string {
|
||||
return this.routeConfig.name || '';
|
||||
}
|
||||
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
|
||||
this.clientRef = clientRef;
|
||||
this.routeConfig = data.route;
|
||||
this.source = data.source;
|
||||
this.enabled = data.enabled;
|
||||
this.overridden = data.overridden;
|
||||
this.storedRouteId = data.storedRouteId;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
public async update(changes: Partial<IRouteConfig>): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
|
||||
'updateRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to update route');
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
|
||||
'deleteRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete route');
|
||||
}
|
||||
}
|
||||
|
||||
public async toggle(enabled: boolean): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to toggle route');
|
||||
}
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public async setOverride(enabled: boolean): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to set route override');
|
||||
}
|
||||
this.overridden = true;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public async removeOverride(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to remove route override');
|
||||
}
|
||||
this.overridden = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteBuilder {
|
||||
private clientRef: DcRouterApiClient;
|
||||
private routeConfig: Partial<IRouteConfig> = {};
|
||||
private isEnabled: boolean = true;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public setName(name: string): this {
|
||||
this.routeConfig.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setMatch(match: IRouteConfig['match']): this {
|
||||
this.routeConfig.match = match;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setAction(action: IRouteConfig['action']): this {
|
||||
this.routeConfig.action = action;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setTls(tls: IRouteConfig['action']['tls']): this {
|
||||
if (!this.routeConfig.action) {
|
||||
this.routeConfig.action = { type: 'forward' } as IRouteConfig['action'];
|
||||
}
|
||||
this.routeConfig.action!.tls = tls;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setEnabled(enabled: boolean): this {
|
||||
this.isEnabled = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
public async save(): Promise<Route> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRoute>(
|
||||
'createRoute',
|
||||
this.clientRef.buildRequestPayload({
|
||||
route: this.routeConfig as IRouteConfig,
|
||||
enabled: this.isEnabled,
|
||||
}) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to create route');
|
||||
}
|
||||
|
||||
// Return a Route instance by re-fetching the list
|
||||
// The created route is programmatic, so we find it by storedRouteId
|
||||
const { routes } = await new RouteManager(this.clientRef).list();
|
||||
const created = routes.find((r) => r.storedRouteId === response.storedRouteId);
|
||||
if (created) {
|
||||
return created;
|
||||
}
|
||||
|
||||
// Fallback: construct from known data
|
||||
return new Route(this.clientRef, {
|
||||
route: this.routeConfig as IRouteConfig,
|
||||
source: 'programmatic',
|
||||
enabled: this.isEnabled,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<{ routes: Route[]; warnings: interfaces.data.IRouteWarning[] }> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetMergedRoutes>(
|
||||
'getMergedRoutes',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return {
|
||||
routes: response.routes.map((r) => new Route(this.clientRef, r)),
|
||||
warnings: response.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
public async create(routeConfig: IRouteConfig, enabled?: boolean): Promise<Route> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRoute>(
|
||||
'createRoute',
|
||||
this.clientRef.buildRequestPayload({ route: routeConfig, enabled: enabled ?? true }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to create route');
|
||||
}
|
||||
return new Route(this.clientRef, {
|
||||
route: routeConfig,
|
||||
source: 'programmatic',
|
||||
enabled: enabled ?? true,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
});
|
||||
}
|
||||
|
||||
public build(): RouteBuilder {
|
||||
return new RouteBuilder(this.clientRef);
|
||||
}
|
||||
}
|
||||
111
ts_apiclient/classes.stats.ts
Normal file
111
ts_apiclient/classes.stats.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
type TTimeRange = '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
|
||||
export class StatsManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async getServer(options?: {
|
||||
timeRange?: TTimeRange;
|
||||
includeHistory?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetServerStatistics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'getServerStatistics',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getEmail(options?: {
|
||||
timeRange?: TTimeRange;
|
||||
domain?: string;
|
||||
includeDetails?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetEmailStatistics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetEmailStatistics>(
|
||||
'getEmailStatistics',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getDns(options?: {
|
||||
timeRange?: TTimeRange;
|
||||
domain?: string;
|
||||
includeQueryTypes?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetDnsStatistics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetDnsStatistics>(
|
||||
'getDnsStatistics',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getRateLimits(options?: {
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
includeBlocked?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetRateLimitStatus['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||
'getRateLimitStatus',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getSecurity(options?: {
|
||||
timeRange?: TTimeRange;
|
||||
includeDetails?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetSecurityMetrics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||
'getSecurityMetrics',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getConnections(options?: {
|
||||
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
|
||||
state?: string;
|
||||
}): Promise<interfaces.requests.IReq_GetActiveConnections['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetActiveConnections>(
|
||||
'getActiveConnections',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getQueues(options?: {
|
||||
queueName?: string;
|
||||
}): Promise<interfaces.requests.IReq_GetQueueStatus['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetQueueStatus>(
|
||||
'getQueueStatus',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getHealth(detailed?: boolean): Promise<interfaces.requests.IReq_GetHealthStatus['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'getHealthStatus',
|
||||
this.clientRef.buildRequestPayload({ detailed }) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getNetwork(): Promise<interfaces.requests.IReq_GetNetworkStats['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetNetworkStats>(
|
||||
'getNetworkStats',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getCombined(sections?: {
|
||||
server?: boolean;
|
||||
email?: boolean;
|
||||
dns?: boolean;
|
||||
security?: boolean;
|
||||
network?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetCombinedMetrics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||
'getCombinedMetrics',
|
||||
this.clientRef.buildRequestPayload({ sections }) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
ts_apiclient/index.ts
Normal file
15
ts_apiclient/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Main client
|
||||
export { DcRouterApiClient, type IDcRouterApiClientOptions } from './classes.dcrouterapiclient.js';
|
||||
|
||||
// Resource classes
|
||||
export { Route, RouteBuilder, RouteManager } from './classes.route.js';
|
||||
export { Certificate, CertificateManager, type ICertificateSummary } from './classes.certificate.js';
|
||||
export { ApiToken, ApiTokenBuilder, ApiTokenManager } from './classes.apitoken.js';
|
||||
export { RemoteIngress, RemoteIngressBuilder, RemoteIngressManager } from './classes.remoteingress.js';
|
||||
export { Email, EmailManager } from './classes.email.js';
|
||||
|
||||
// Read-only managers
|
||||
export { StatsManager } from './classes.stats.js';
|
||||
export { ConfigManager } from './classes.config.js';
|
||||
export { LogManager } from './classes.logs.js';
|
||||
export { RadiusManager, RadiusClientManager, RadiusVlanManager, RadiusSessionManager } from './classes.radius.js';
|
||||
8
ts_apiclient/plugins.ts
Normal file
8
ts_apiclient/plugins.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||
|
||||
export {
|
||||
typedrequest,
|
||||
typedrequestInterfaces,
|
||||
};
|
||||
279
ts_apiclient/readme.md
Normal file
279
ts_apiclient/readme.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# @serve.zone/dcrouter-apiclient
|
||||
|
||||
A typed, object-oriented API client for DcRouter with a fluent builder pattern. 🔧
|
||||
|
||||
Programmatically manage your DcRouter instance — routes, certificates, API tokens, remote ingress edges, RADIUS, email operations, and more — all with full TypeScript type safety and an intuitive OO interface.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
```
|
||||
|
||||
Or import directly from the main package:
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
|
||||
|
||||
// Authenticate
|
||||
await client.login('admin', 'password');
|
||||
|
||||
// List routes
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
console.log(`${routes.length} routes, ${warnings.length} warnings`);
|
||||
|
||||
// Check health
|
||||
const { health } = await client.stats.getHealth();
|
||||
console.log(`Healthy: ${health.healthy}`);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 🔐 Authentication
|
||||
|
||||
```typescript
|
||||
// Login with credentials — identity is stored and auto-injected into all subsequent requests
|
||||
const identity = await client.login('admin', 'password');
|
||||
|
||||
// Verify current session
|
||||
const { valid } = await client.verifyIdentity();
|
||||
|
||||
// Logout
|
||||
await client.logout();
|
||||
|
||||
// Or use an API token for programmatic access (route management only)
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
apiToken: 'dcr_your_token_here',
|
||||
});
|
||||
```
|
||||
|
||||
### 🌐 Routes — OO Resources + Builder
|
||||
|
||||
Routes are returned as `Route` instances with methods for update, delete, toggle, and overrides:
|
||||
|
||||
```typescript
|
||||
// List all routes (hardcoded + programmatic)
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
|
||||
// Inspect a route
|
||||
const route = routes[0];
|
||||
console.log(route.name, route.source, route.enabled);
|
||||
|
||||
// Modify a programmatic route
|
||||
await route.update({ name: 'renamed-route' });
|
||||
await route.toggle(false);
|
||||
await route.delete();
|
||||
|
||||
// Override a hardcoded route (disable it)
|
||||
const hardcodedRoute = routes.find(r => r.source === 'hardcoded');
|
||||
await hardcodedRoute.setOverride(false);
|
||||
await hardcodedRoute.removeOverride();
|
||||
```
|
||||
|
||||
**Builder pattern** for creating new routes:
|
||||
|
||||
```typescript
|
||||
const newRoute = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
||||
.setTls({ mode: 'terminate', certificate: 'auto' })
|
||||
.setEnabled(true)
|
||||
.save();
|
||||
|
||||
// Or use quick creation
|
||||
const route = await client.routes.create(routeConfig);
|
||||
```
|
||||
|
||||
### 🔑 API Tokens
|
||||
|
||||
```typescript
|
||||
// List existing tokens
|
||||
const tokens = await client.apiTokens.list();
|
||||
|
||||
// Create with builder
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('ci-pipeline')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.addScope('config:read')
|
||||
.setExpiresInDays(90)
|
||||
.save();
|
||||
|
||||
console.log(token.tokenValue); // Only available at creation time!
|
||||
|
||||
// Manage tokens
|
||||
await token.toggle(false); // Disable
|
||||
const newValue = await token.roll(); // Regenerate secret
|
||||
await token.revoke(); // Delete
|
||||
```
|
||||
|
||||
### 🔐 Certificates
|
||||
|
||||
```typescript
|
||||
const { certificates, summary } = await client.certificates.list();
|
||||
console.log(`${summary.valid} valid, ${summary.expiring} expiring, ${summary.failed} failed`);
|
||||
|
||||
// Operate on individual certificates
|
||||
const cert = certificates[0];
|
||||
await cert.reprovision();
|
||||
const exported = await cert.export();
|
||||
await cert.delete();
|
||||
|
||||
// Import a certificate
|
||||
await client.certificates.import({
|
||||
id: 'cert-id',
|
||||
domainName: 'example.com',
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 3600 * 1000,
|
||||
privateKey: '...',
|
||||
publicKey: '...',
|
||||
csr: '...',
|
||||
});
|
||||
```
|
||||
|
||||
### 🌍 Remote Ingress
|
||||
|
||||
```typescript
|
||||
// List edges and their statuses
|
||||
const edges = await client.remoteIngress.list();
|
||||
const statuses = await client.remoteIngress.getStatuses();
|
||||
|
||||
// Create with builder
|
||||
const edge = await client.remoteIngress.build()
|
||||
.setName('edge-nyc-01')
|
||||
.setListenPorts([80, 443])
|
||||
.setAutoDerivePorts(true)
|
||||
.setTags(['us-east'])
|
||||
.save();
|
||||
|
||||
// Manage an edge
|
||||
await edge.update({ name: 'edge-nyc-02' });
|
||||
const newSecret = await edge.regenerateSecret();
|
||||
const token = await edge.getConnectionToken();
|
||||
await edge.delete();
|
||||
```
|
||||
|
||||
### 📊 Statistics (Read-Only)
|
||||
|
||||
```typescript
|
||||
const serverStats = await client.stats.getServer({ timeRange: '24h', includeHistory: true });
|
||||
const emailStats = await client.stats.getEmail({ domain: 'example.com' });
|
||||
const dnsStats = await client.stats.getDns();
|
||||
const security = await client.stats.getSecurity({ includeDetails: true });
|
||||
const connections = await client.stats.getConnections({ protocol: 'https' });
|
||||
const queues = await client.stats.getQueues();
|
||||
const health = await client.stats.getHealth(true);
|
||||
const network = await client.stats.getNetwork();
|
||||
const combined = await client.stats.getCombined({ server: true, email: true });
|
||||
```
|
||||
|
||||
### ⚙️ Configuration & Logs
|
||||
|
||||
```typescript
|
||||
// Read-only configuration
|
||||
const config = await client.config.get();
|
||||
const emailSection = await client.config.get('email');
|
||||
|
||||
// Logs
|
||||
const { logs, total, hasMore } = await client.logs.getRecent({
|
||||
level: 'error',
|
||||
category: 'smtp',
|
||||
limit: 50,
|
||||
});
|
||||
```
|
||||
|
||||
### 📧 Email Operations
|
||||
|
||||
```typescript
|
||||
const emails = await client.emails.list();
|
||||
const email = emails[0];
|
||||
const detail = await email.getDetail();
|
||||
await email.resend();
|
||||
|
||||
// Or use the manager directly
|
||||
const detail2 = await client.emails.getDetail('email-id');
|
||||
await client.emails.resend('email-id');
|
||||
```
|
||||
|
||||
### 📡 RADIUS
|
||||
|
||||
```typescript
|
||||
// Client management
|
||||
const clients = await client.radius.clients.list();
|
||||
await client.radius.clients.set({
|
||||
name: 'switch-1',
|
||||
ipRange: '192.168.1.0/24',
|
||||
secret: 'shared-secret',
|
||||
enabled: true,
|
||||
});
|
||||
await client.radius.clients.remove('switch-1');
|
||||
|
||||
// VLAN management
|
||||
const { mappings, config: vlanConfig } = await client.radius.vlans.list();
|
||||
await client.radius.vlans.set({ mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true });
|
||||
const result = await client.radius.vlans.testAssignment('aa:bb:cc:dd:ee:ff');
|
||||
await client.radius.vlans.updateConfig({ defaultVlan: 200 });
|
||||
|
||||
// Sessions
|
||||
const { sessions } = await client.radius.sessions.list({ vlanId: 10 });
|
||||
await client.radius.sessions.disconnect('session-id', 'Admin disconnect');
|
||||
|
||||
// Statistics & Accounting
|
||||
const stats = await client.radius.getStatistics();
|
||||
const summary = await client.radius.getAccountingSummary(startTime, endTime);
|
||||
```
|
||||
|
||||
## API Surface
|
||||
|
||||
| Manager | Methods |
|
||||
|---------|---------|
|
||||
| `client.login()` / `logout()` / `verifyIdentity()` | Authentication |
|
||||
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
|
||||
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
|
||||
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
|
||||
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
|
||||
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getRateLimits()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
|
||||
| `client.config` | `get(section?)` |
|
||||
| `client.logs` | `getRecent()`, `getStream()` |
|
||||
| `client.emails` | `list()`, `getDetail()`, `resend()` → Email: `getDetail()`, `resend()` |
|
||||
| `client.radius` | `.clients.list/set/remove()`, `.vlans.list/set/remove/updateConfig/testAssignment()`, `.sessions.list/disconnect()`, `getStatistics()`, `getAccountingSummary()` |
|
||||
|
||||
## Architecture
|
||||
|
||||
The client uses HTTP-based [TypedRequest](https://code.foss.global/api.global/typedrequest) for transport. All requests are sent as POST to `{baseUrl}/typedrequest`. Authentication (JWT identity and/or API token) is automatically injected into every request payload via `buildRequestPayload()`.
|
||||
|
||||
Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) hold a reference to the client and provide instance methods that fire the appropriate TypedRequest operations. Builder classes (`RouteBuilder`, `ApiTokenBuilder`, `RemoteIngressBuilder`) use fluent chaining and a terminal `.save()` method.
|
||||
|
||||
## 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.
|
||||
|
||||
**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.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
3
ts_apiclient/tspublish.json
Normal file
3
ts_apiclient/tspublish.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 4
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './auth.js';
|
||||
export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
57
ts_interfaces/data/remoteingress.ts
Normal file
57
ts_interfaces/data/remoteingress.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
/**
|
||||
* A stored remote ingress edge registration.
|
||||
*/
|
||||
export interface IRemoteIngress {
|
||||
id: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
listenPorts: number[];
|
||||
enabled: boolean;
|
||||
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
|
||||
autoDerivePorts: boolean;
|
||||
tags?: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
/** Effective ports (union of manual + derived) — only present in API responses. */
|
||||
effectiveListenPorts?: number[];
|
||||
/** Ports explicitly set by the user — only present in API responses. */
|
||||
manualPorts?: number[];
|
||||
/** Ports auto-derived from route configs — only present in API responses. */
|
||||
derivedPorts?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime status of a remote ingress edge.
|
||||
*/
|
||||
export interface IRemoteIngressStatus {
|
||||
edgeId: string;
|
||||
connected: boolean;
|
||||
publicIp: string | null;
|
||||
activeTunnels: number;
|
||||
lastHeartbeat: number | null;
|
||||
connectedAt: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level remote ingress configuration.
|
||||
* When attached to a route, signals that traffic for this route
|
||||
* should be accepted from remote edge nodes.
|
||||
*/
|
||||
export interface IRouteRemoteIngress {
|
||||
/** Whether this route receives traffic from edge nodes */
|
||||
enabled: boolean;
|
||||
/** Optional filter: only edges whose id or tags match get this route's ports.
|
||||
* When absent, the route applies to all edges. */
|
||||
edgeFilter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended route config used within dcrouter.
|
||||
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
|
||||
* SmartProxy ignores unknown properties at runtime.
|
||||
*/
|
||||
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||
remoteIngress?: IRouteRemoteIngress;
|
||||
};
|
||||
83
ts_interfaces/data/route-management.ts
Normal file
83
ts_interfaces/data/route-management.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Data Types
|
||||
// ============================================================================
|
||||
|
||||
export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage';
|
||||
|
||||
/**
|
||||
* A merged route combining hardcoded and programmatic sources.
|
||||
*/
|
||||
export interface IMergedRoute {
|
||||
route: IRouteConfig;
|
||||
source: 'hardcoded' | 'programmatic';
|
||||
enabled: boolean;
|
||||
overridden: boolean;
|
||||
storedRouteId?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning generated during route merge/startup.
|
||||
*/
|
||||
export interface IRouteWarning {
|
||||
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
|
||||
routeName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public info about an API token (never includes the hash).
|
||||
*/
|
||||
export interface IApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage Schemas (persisted via StorageManager)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A programmatic route stored in /config-api/routes/{id}.json
|
||||
*/
|
||||
export interface IStoredRoute {
|
||||
id: string;
|
||||
route: IRouteConfig;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
|
||||
*/
|
||||
export interface IRouteOverride {
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored API token, stored in /config-api/tokens/{id}.json
|
||||
*/
|
||||
export interface IStoredApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
scopes: TApiTokenScope[];
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
createdBy: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -17,6 +17,18 @@ export interface IServerStats {
|
||||
};
|
||||
activeConnections: number;
|
||||
totalConnections: number;
|
||||
requestsPerSecond: number;
|
||||
throughput: {
|
||||
bytesIn: number;
|
||||
bytesOut: number;
|
||||
bytesInPerSecond: number;
|
||||
bytesOutPerSecond: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ITimeSeriesPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface IEmailStats {
|
||||
@@ -28,6 +40,11 @@ export interface IEmailStats {
|
||||
averageDeliveryTime: number;
|
||||
deliveryRate: number;
|
||||
bounceRate: number;
|
||||
timeSeries?: {
|
||||
sent: ITimeSeriesPoint[];
|
||||
received: ITimeSeriesPoint[];
|
||||
failed: ITimeSeriesPoint[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDnsStats {
|
||||
@@ -40,6 +57,16 @@ export interface IDnsStats {
|
||||
queryTypes: {
|
||||
[key: string]: number;
|
||||
};
|
||||
timeSeries?: {
|
||||
queries: ITimeSeriesPoint[];
|
||||
};
|
||||
recentQueries?: Array<{
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
type: string;
|
||||
answered: boolean;
|
||||
responseTimeMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface IRateLimitInfo {
|
||||
@@ -51,6 +78,17 @@ export interface IRateLimitInfo {
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
export interface ISecurityEvent {
|
||||
timestamp: number;
|
||||
level: string;
|
||||
type: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
ipAddress?: string;
|
||||
domain?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface ISecurityMetrics {
|
||||
blockedIPs: string[];
|
||||
reputationScores: {
|
||||
@@ -61,6 +99,7 @@ export interface ISecurityMetrics {
|
||||
phishingDetected: number;
|
||||
authenticationFailures: number;
|
||||
suspiciousActivities: number;
|
||||
recentEvents?: ISecurityEvent[];
|
||||
}
|
||||
|
||||
export interface ILogEntry {
|
||||
@@ -109,6 +148,10 @@ export interface INetworkMetrics {
|
||||
in: number;
|
||||
out: number;
|
||||
};
|
||||
totalBytes?: {
|
||||
in: number;
|
||||
out: number;
|
||||
};
|
||||
activeConnections: number;
|
||||
connectionDetails: IConnectionDetails[];
|
||||
topEndpoints: Array<{
|
||||
@@ -119,6 +162,9 @@ export interface INetworkMetrics {
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
|
||||
requestsPerSecond?: number;
|
||||
requestsTotal?: number;
|
||||
}
|
||||
|
||||
export interface IConnectionDetails {
|
||||
|
||||
@@ -82,6 +82,22 @@ interface IIdentity {
|
||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||
|
||||
#### Route Management Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `IMergedRoute` | Combined route: routeConfig, source (hardcoded/programmatic), enabled, overridden |
|
||||
| `IRouteWarning` | Merge warning: disabled-hardcoded, disabled-programmatic, orphaned-override |
|
||||
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
||||
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
||||
|
||||
#### Remote Ingress Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` property |
|
||||
|
||||
### Request Interfaces (`requests`)
|
||||
|
||||
TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||
@@ -120,13 +136,74 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||
#### 📧 Email Operations
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetQueuedEmails` | `getQueuedEmails` | List queued emails |
|
||||
| `IReq_GetSentEmails` | `getSentEmails` | List delivered emails |
|
||||
| `IReq_GetFailedEmails` | `getFailedEmails` | List failed emails |
|
||||
| `IReq_GetAllEmails` | `getAllEmails` | List all emails |
|
||||
| `IReq_GetEmailDetail` | `getEmailDetail` | Full detail for a specific email |
|
||||
| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email |
|
||||
| `IReq_GetSecurityIncidents` | `getSecurityIncidents` | Security events |
|
||||
| `IReq_GetBounceRecords` | `getBounceRecords` | Bounce records |
|
||||
| `IReq_RemoveFromSuppressionList` | `removeFromSuppressionList` | Unsuppress an address |
|
||||
|
||||
#### 🛣️ Route Management
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetMergedRoutes` | `getMergedRoutes` | List all routes (hardcoded + programmatic) |
|
||||
| `IReq_CreateRoute` | `createRoute` | Create a new programmatic route |
|
||||
| `IReq_UpdateRoute` | `updateRoute` | Update a programmatic route |
|
||||
| `IReq_DeleteRoute` | `deleteRoute` | Delete a programmatic route |
|
||||
| `IReq_ToggleRoute` | `toggleRoute` | Enable/disable a programmatic route |
|
||||
| `IReq_SetRouteOverride` | `setRouteOverride` | Override a hardcoded route |
|
||||
| `IReq_RemoveRouteOverride` | `removeRouteOverride` | Remove a route override |
|
||||
|
||||
#### 🔑 API Token Management
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_CreateApiToken` | `createApiToken` | Create a new API token |
|
||||
| `IReq_ListApiTokens` | `listApiTokens` | List all tokens |
|
||||
| `IReq_RevokeApiToken` | `revokeApiToken` | Revoke (delete) a token |
|
||||
| `IReq_RollApiToken` | `rollApiToken` | Regenerate token secret |
|
||||
| `IReq_ToggleApiToken` | `toggleApiToken` | Enable/disable a token |
|
||||
|
||||
#### 🔐 Certificates
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetCertificateOverview` | `getCertificateOverview` | Domain-centric certificate status |
|
||||
| `IReq_ReprovisionCertificate` | `reprovisionCertificate` | Reprovision by route name (legacy) |
|
||||
| `IReq_ReprovisionCertificateDomain` | `reprovisionCertificateDomain` | Reprovision by domain (preferred) |
|
||||
| `IReq_ImportCertificate` | `importCertificate` | Import a certificate |
|
||||
| `IReq_ExportCertificate` | `exportCertificate` | Export a certificate |
|
||||
| `IReq_DeleteCertificate` | `deleteCertificate` | Delete a certificate |
|
||||
|
||||
#### Certificate Types
|
||||
```typescript
|
||||
type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
|
||||
type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
|
||||
|
||||
interface ICertificateInfo {
|
||||
domain: string;
|
||||
routeNames: string[];
|
||||
status: TCertificateStatus;
|
||||
source: TCertificateSource;
|
||||
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||
expiryDate?: string;
|
||||
issuer?: string;
|
||||
issuedAt?: string;
|
||||
error?: string;
|
||||
canReprovision: boolean;
|
||||
backoffInfo?: {
|
||||
failures: number;
|
||||
retryAfter?: string;
|
||||
lastError?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 🌍 Remote Ingress
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_CreateRemoteIngress` | `createRemoteIngress` | Register a new edge node |
|
||||
| `IReq_DeleteRemoteIngress` | `deleteRemoteIngress` | Remove an edge registration |
|
||||
| `IReq_UpdateRemoteIngress` | `updateRemoteIngress` | Update edge settings |
|
||||
| `IReq_RegenerateRemoteIngressSecret` | `regenerateRemoteIngressSecret` | Issue a new secret |
|
||||
| `IReq_GetRemoteIngresses` | `getRemoteIngresses` | List all edge registrations |
|
||||
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
||||
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
|
||||
|
||||
#### 📡 RADIUS
|
||||
| Interface | Method | Description |
|
||||
@@ -145,6 +222,8 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||
|
||||
## Example: Full API Integration
|
||||
|
||||
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
||||
|
||||
```typescript
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||
@@ -152,7 +231,7 @@ import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||
// 1. Login
|
||||
const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'adminLogin'
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const loginResponse = await loginClient.fire({
|
||||
@@ -168,19 +247,35 @@ const metricsClient = new typedrequest.TypedRequest<requests.IReq_GetCombinedMet
|
||||
);
|
||||
|
||||
const metrics = await metricsClient.fire({ identity });
|
||||
console.log('Server:', metrics.serverStats);
|
||||
console.log('Email:', metrics.emailStats);
|
||||
console.log('DNS:', metrics.dnsStats);
|
||||
console.log('Security:', metrics.securityMetrics);
|
||||
console.log('Server:', metrics.metrics.server);
|
||||
console.log('Email:', metrics.metrics.email);
|
||||
|
||||
// 3. Check email queues
|
||||
const queueClient = new typedrequest.TypedRequest<requests.IReq_GetQueuedEmails>(
|
||||
// 3. Check certificate status
|
||||
const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOverview>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getQueuedEmails'
|
||||
'getCertificateOverview'
|
||||
);
|
||||
|
||||
const queued = await queueClient.fire({ identity });
|
||||
console.log('Queued emails:', queued.emails.length);
|
||||
const certs = await certClient.fire({ identity });
|
||||
console.log(`Certificates: ${certs.summary.valid} valid, ${certs.summary.failed} failed`);
|
||||
|
||||
// 4. List remote ingress edges
|
||||
const edgesClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngresses>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getRemoteIngresses'
|
||||
);
|
||||
|
||||
const edges = await edgesClient.fire({ identity });
|
||||
console.log('Registered edges:', edges.edges.length);
|
||||
|
||||
// 5. Generate a connection token for an edge
|
||||
const tokenClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngressConnectionToken>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getRemoteIngressConnectionToken'
|
||||
);
|
||||
|
||||
const tokenResponse = await tokenClient.fire({ identity, edgeId: edges.edges[0].id });
|
||||
console.log('Connection token:', tokenResponse.token);
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
103
ts_interfaces/requests/api-tokens.ts
Normal file
103
ts_interfaces/requests/api-tokens.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IApiTokenInfo, TApiTokenScope } from '../data/route-management.js';
|
||||
|
||||
// ============================================================================
|
||||
// API Token Management Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new API token. Returns the raw token value once (never shown again).
|
||||
* Admin JWT only — tokens cannot create tokens.
|
||||
*/
|
||||
export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateApiToken
|
||||
> {
|
||||
method: 'createApiToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
expiresInDays?: number | null;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
tokenId?: string;
|
||||
tokenValue?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API tokens (without hashes).
|
||||
*/
|
||||
export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListApiTokens
|
||||
> {
|
||||
method: 'listApiTokens';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
tokens: IApiTokenInfo[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) an API token.
|
||||
*/
|
||||
export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RevokeApiToken
|
||||
> {
|
||||
method: 'revokeApiToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll (regenerate) an API token's secret. Returns the new raw token value once.
|
||||
* Admin JWT only.
|
||||
*/
|
||||
export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RollApiToken
|
||||
> {
|
||||
method: 'rollApiToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
tokenValue?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable an API token.
|
||||
*/
|
||||
export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ToggleApiToken
|
||||
> {
|
||||
method: 'toggleApiToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
141
ts_interfaces/requests/certificate.ts
Normal file
141
ts_interfaces/requests/certificate.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
export type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
|
||||
export type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
|
||||
|
||||
export interface ICertificateInfo {
|
||||
domain: string;
|
||||
routeNames: string[];
|
||||
status: TCertificateStatus;
|
||||
source: TCertificateSource;
|
||||
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||
expiryDate?: string; // ISO string
|
||||
issuer?: string;
|
||||
issuedAt?: string; // ISO string
|
||||
error?: string; // if status === 'failed'
|
||||
canReprovision: boolean; // true for acme/provision-function routes
|
||||
backoffInfo?: {
|
||||
failures: number;
|
||||
retryAfter?: string; // ISO string
|
||||
lastError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetCertificateOverview
|
||||
> {
|
||||
method: 'getCertificateOverview';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
certificates: ICertificateInfo[];
|
||||
summary: {
|
||||
total: number;
|
||||
valid: number;
|
||||
expiring: number;
|
||||
expired: number;
|
||||
failed: number;
|
||||
unknown: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy route-based reprovision (kept for backward compat)
|
||||
export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ReprovisionCertificate
|
||||
> {
|
||||
method: 'reprovisionCertificate';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
routeName: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Domain-based reprovision (preferred)
|
||||
export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ReprovisionCertificateDomain
|
||||
> {
|
||||
method: 'reprovisionCertificateDomain';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Delete a certificate by domain
|
||||
export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteCertificate
|
||||
> {
|
||||
method: 'deleteCertificate';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Export a certificate as ICert JSON
|
||||
export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ExportCertificate
|
||||
> {
|
||||
method: 'exportCertificate';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
cert?: {
|
||||
id: string;
|
||||
domainName: string;
|
||||
created: number;
|
||||
validUntil: number;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
csr: string;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Import a certificate from ICert JSON
|
||||
export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ImportCertificate
|
||||
> {
|
||||
method: 'importCertificate';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
cert: {
|
||||
id: string;
|
||||
domainName: string;
|
||||
created: number;
|
||||
validUntil: number;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
csr: string;
|
||||
};
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,79 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
export interface IConfigData {
|
||||
system: {
|
||||
baseDir: string;
|
||||
dataDir: string;
|
||||
publicIp: string | null;
|
||||
proxyIps: string[];
|
||||
uptime: number;
|
||||
storageBackend: 'filesystem' | 'custom' | 'memory';
|
||||
storagePath: string | null;
|
||||
};
|
||||
smartProxy: {
|
||||
enabled: boolean;
|
||||
routeCount: number;
|
||||
acme: {
|
||||
enabled: boolean;
|
||||
accountEmail: string;
|
||||
useProduction: boolean;
|
||||
autoRenew: boolean;
|
||||
renewThresholdDays: number;
|
||||
} | null;
|
||||
};
|
||||
email: {
|
||||
enabled: boolean;
|
||||
ports: number[];
|
||||
portMapping: Record<string, number> | null;
|
||||
hostname: string | null;
|
||||
domains: string[];
|
||||
emailRouteCount: number;
|
||||
receivedEmailsPath: string | null;
|
||||
};
|
||||
dns: {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
nsDomains: string[];
|
||||
scopes: string[];
|
||||
recordCount: number;
|
||||
records: Array<{ name: string; type: string; value: string; ttl?: number }>;
|
||||
dnsChallenge: boolean;
|
||||
};
|
||||
tls: {
|
||||
contactEmail: string | null;
|
||||
domain: string | null;
|
||||
source: 'acme' | 'static' | 'none';
|
||||
certPath: string | null;
|
||||
keyPath: string | null;
|
||||
};
|
||||
cache: {
|
||||
enabled: boolean;
|
||||
storagePath: string | null;
|
||||
dbName: string | null;
|
||||
defaultTTLDays: number;
|
||||
cleanupIntervalHours: number;
|
||||
ttlConfig: Record<string, number>;
|
||||
};
|
||||
radius: {
|
||||
enabled: boolean;
|
||||
authPort: number | null;
|
||||
acctPort: number | null;
|
||||
bindAddress: string | null;
|
||||
clientCount: number;
|
||||
vlanDefaultVlan: number | null;
|
||||
vlanAllowUnknownMacs: boolean | null;
|
||||
vlanMappingCount: number;
|
||||
};
|
||||
remoteIngress: {
|
||||
enabled: boolean;
|
||||
tunnelPort: number | null;
|
||||
hubDomain: string | null;
|
||||
tlsMode: 'custom' | 'acme' | 'self-signed';
|
||||
connectedEdgeIps: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// Get Configuration (read-only)
|
||||
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -8,11 +81,11 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'getConfiguration';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
section?: string;
|
||||
};
|
||||
response: {
|
||||
config: any;
|
||||
config: IConfigData;
|
||||
section?: string;
|
||||
};
|
||||
}
|
||||
@@ -2,162 +2,93 @@ import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
// ============================================================================
|
||||
// Email Queue Item Interface (matches backend IQueueItem)
|
||||
// Catalog-compatible email types (matches @serve.zone/catalog IEmail/IEmailDetail)
|
||||
// ============================================================================
|
||||
export type TEmailQueueStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
export type TEmailStatus = 'delivered' | 'bounced' | 'rejected' | 'deferred' | 'pending';
|
||||
export type TEmailDirection = 'inbound' | 'outbound';
|
||||
|
||||
export interface IEmailQueueItem {
|
||||
export interface IEmail {
|
||||
id: string;
|
||||
processingMode: 'forward' | 'mta' | 'process';
|
||||
status: TEmailQueueStatus;
|
||||
attempts: number;
|
||||
nextAttempt: number; // timestamp
|
||||
lastError?: string;
|
||||
createdAt: number; // timestamp
|
||||
updatedAt: number; // timestamp
|
||||
deliveredAt?: number; // timestamp
|
||||
// Email details extracted from processingResult
|
||||
from?: string;
|
||||
to?: string[];
|
||||
subject?: string;
|
||||
direction: TEmailDirection;
|
||||
status: TEmailStatus;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
timestamp: string;
|
||||
messageId: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface ISmtpLogEntry {
|
||||
timestamp: string;
|
||||
direction: 'client' | 'server';
|
||||
command: string;
|
||||
responseCode?: number;
|
||||
}
|
||||
|
||||
export interface IConnectionInfo {
|
||||
sourceIp: string;
|
||||
sourceHostname: string;
|
||||
destinationIp: string;
|
||||
destinationPort: number;
|
||||
tlsVersion: string;
|
||||
tlsCipher: string;
|
||||
authenticated: boolean;
|
||||
authMethod: string;
|
||||
authUser: string;
|
||||
}
|
||||
|
||||
export interface IAuthenticationResults {
|
||||
spf: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none';
|
||||
spfDomain: string;
|
||||
dkim: 'pass' | 'fail' | 'none';
|
||||
dkimDomain: string;
|
||||
dmarc: 'pass' | 'fail' | 'none';
|
||||
dmarcPolicy: string;
|
||||
}
|
||||
|
||||
export interface IEmailDetail extends IEmail {
|
||||
toList: string[];
|
||||
cc?: string[];
|
||||
smtpLog: ISmtpLogEntry[];
|
||||
connectionInfo: IConnectionInfo;
|
||||
authenticationResults: IAuthenticationResults;
|
||||
rejectionReason?: string;
|
||||
bounceMessage?: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bounce Record Interface (matches backend BounceRecord)
|
||||
// Get All Emails Request
|
||||
// ============================================================================
|
||||
export type TBounceType =
|
||||
| 'invalid_recipient'
|
||||
| 'domain_not_found'
|
||||
| 'mailbox_full'
|
||||
| 'mailbox_inactive'
|
||||
| 'blocked'
|
||||
| 'spam_related'
|
||||
| 'policy_related'
|
||||
| 'server_unavailable'
|
||||
| 'temporary_failure'
|
||||
| 'quota_exceeded'
|
||||
| 'network_error'
|
||||
| 'timeout'
|
||||
| 'auto_response'
|
||||
| 'challenge_response'
|
||||
| 'unknown';
|
||||
|
||||
export type TBounceCategory = 'hard' | 'soft' | 'auto_response' | 'unknown';
|
||||
|
||||
export interface IBounceRecord {
|
||||
id: string;
|
||||
originalEmailId?: string;
|
||||
recipient: string;
|
||||
sender: string;
|
||||
domain: string;
|
||||
subject?: string;
|
||||
bounceType: TBounceType;
|
||||
bounceCategory: TBounceCategory;
|
||||
timestamp: number;
|
||||
smtpResponse?: string;
|
||||
diagnosticCode?: string;
|
||||
statusCode?: string;
|
||||
processed: boolean;
|
||||
retryCount?: number;
|
||||
nextRetryTime?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security Incident Interface (matches backend ISecurityEvent)
|
||||
// ============================================================================
|
||||
export type TSecurityLogLevel = 'info' | 'warn' | 'error' | 'critical';
|
||||
|
||||
export type TSecurityEventType =
|
||||
| 'authentication'
|
||||
| 'access_control'
|
||||
| 'email_validation'
|
||||
| 'email_processing'
|
||||
| 'email_forwarding'
|
||||
| 'email_delivery'
|
||||
| 'dkim'
|
||||
| 'spf'
|
||||
| 'dmarc'
|
||||
| 'rate_limit'
|
||||
| 'rate_limiting'
|
||||
| 'spam'
|
||||
| 'malware'
|
||||
| 'connection'
|
||||
| 'data_exposure'
|
||||
| 'configuration'
|
||||
| 'ip_reputation'
|
||||
| 'rejected_connection';
|
||||
|
||||
export interface ISecurityIncident {
|
||||
timestamp: number;
|
||||
level: TSecurityLogLevel;
|
||||
type: TSecurityEventType;
|
||||
message: string;
|
||||
details?: any;
|
||||
ipAddress?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
emailId?: string;
|
||||
domain?: string;
|
||||
action?: string;
|
||||
result?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Queued Emails Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetQueuedEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetQueuedEmails
|
||||
IReq_GetAllEmails
|
||||
> {
|
||||
method: 'getQueuedEmails';
|
||||
method: 'getAllEmails';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
status?: TEmailQueueStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
emails: IEmail[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Sent Emails Request
|
||||
// Get Email Detail Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetSentEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSentEmails
|
||||
IReq_GetEmailDetail
|
||||
> {
|
||||
method: 'getSentEmails';
|
||||
method: 'getEmailDetail';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
identity: authInterfaces.IIdentity;
|
||||
emailId: string;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Failed Emails Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetFailedEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetFailedEmails
|
||||
> {
|
||||
method: 'getFailedEmails';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
email: IEmailDetail | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,7 +101,7 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
|
||||
> {
|
||||
method: 'resendEmail';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
emailId: string;
|
||||
};
|
||||
response: {
|
||||
@@ -179,61 +110,3 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Security Incidents Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetSecurityIncidents extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityIncidents
|
||||
> {
|
||||
method: 'getSecurityIncidents';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
type?: TSecurityEventType;
|
||||
level?: TSecurityLogLevel;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
incidents: ISecurityIncident[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Bounce Records Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetBounceRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetBounceRecords
|
||||
> {
|
||||
method: 'getBounceRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
records: IBounceRecord[];
|
||||
suppressionList: string[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Remove from Suppression List Request
|
||||
// ============================================================================
|
||||
export interface IReq_RemoveFromSuppressionList extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveFromSuppressionList
|
||||
> {
|
||||
method: 'removeFromSuppressionList';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
email: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,3 +5,7 @@ export * from './stats.js';
|
||||
export * from './combined.stats.js';
|
||||
export * from './radius.js';
|
||||
export * from './email-ops.js';
|
||||
export * from './certificate.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './api-tokens.js';
|
||||
@@ -9,7 +9,7 @@ export interface IReq_GetRecentLogs extends plugins.typedrequestInterfaces.imple
|
||||
> {
|
||||
method: 'getRecentLogs';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
limit?: number;
|
||||
@@ -31,7 +31,7 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
|
||||
> {
|
||||
method: 'getLogStream';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
follow?: boolean;
|
||||
filters?: {
|
||||
level?: string[];
|
||||
@@ -42,3 +42,15 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
|
||||
logStream: plugins.typedrequestInterfaces.IVirtualStream;
|
||||
};
|
||||
}
|
||||
|
||||
// Push Log Entry (server → client via TypedSocket)
|
||||
export interface IReq_PushLogEntry extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushLogEntry
|
||||
> {
|
||||
method: 'pushLogEntry';
|
||||
request: {
|
||||
entry: statsInterfaces.ILogEntry;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export interface IReq_GetRadiusClients extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'getRadiusClients';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
clients: Array<{
|
||||
@@ -35,7 +35,7 @@ export interface IReq_SetRadiusClient extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'setRadiusClient';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
client: {
|
||||
name: string;
|
||||
ipRange: string;
|
||||
@@ -59,7 +59,7 @@ export interface IReq_RemoveRadiusClient extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'removeRadiusClient';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
name: string;
|
||||
};
|
||||
response: {
|
||||
@@ -81,7 +81,7 @@ export interface IReq_GetVlanMappings extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'getVlanMappings';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
mappings: Array<{
|
||||
@@ -108,7 +108,7 @@ export interface IReq_SetVlanMapping extends plugins.typedrequestInterfaces.impl
|
||||
> {
|
||||
method: 'setVlanMapping';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
mapping: {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
@@ -139,7 +139,7 @@ export interface IReq_RemoveVlanMapping extends plugins.typedrequestInterfaces.i
|
||||
> {
|
||||
method: 'removeVlanMapping';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
mac: string;
|
||||
};
|
||||
response: {
|
||||
@@ -157,7 +157,7 @@ export interface IReq_UpdateVlanConfig extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'updateVlanConfig';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
defaultVlan?: number;
|
||||
allowUnknownMacs?: boolean;
|
||||
};
|
||||
@@ -179,7 +179,7 @@ export interface IReq_TestVlanAssignment extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'testVlanAssignment';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
mac: string;
|
||||
};
|
||||
response: {
|
||||
@@ -207,7 +207,7 @@ export interface IReq_GetRadiusSessions extends plugins.typedrequestInterfaces.i
|
||||
> {
|
||||
method: 'getRadiusSessions';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
filter?: {
|
||||
username?: string;
|
||||
nasIpAddress?: string;
|
||||
@@ -243,7 +243,7 @@ export interface IReq_DisconnectRadiusSession extends plugins.typedrequestInterf
|
||||
> {
|
||||
method: 'disconnectRadiusSession';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
sessionId: string;
|
||||
reason?: string;
|
||||
};
|
||||
@@ -262,7 +262,7 @@ export interface IReq_GetRadiusAccountingSummary extends plugins.typedrequestInt
|
||||
> {
|
||||
method: 'getRadiusAccountingSummary';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
@@ -296,7 +296,7 @@ export interface IReq_GetRadiusStatistics extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'getRadiusStatistics';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
stats: {
|
||||
|
||||
140
ts_interfaces/requests/remoteingress.ts
Normal file
140
ts_interfaces/requests/remoteingress.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
import type { IRemoteIngress, IRemoteIngressStatus } from '../data/remoteingress.js';
|
||||
|
||||
// ============================================================================
|
||||
// Remote Ingress Edge Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new remote ingress edge registration.
|
||||
*/
|
||||
export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateRemoteIngress
|
||||
> {
|
||||
method: 'createRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
name: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
tags?: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
edge: IRemoteIngress;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a remote ingress edge registration.
|
||||
*/
|
||||
export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteRemoteIngress
|
||||
> {
|
||||
method: 'deleteRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a remote ingress edge registration.
|
||||
*/
|
||||
export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateRemoteIngress
|
||||
> {
|
||||
method: 'updateRemoteIngress';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
name?: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
enabled?: boolean;
|
||||
tags?: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
edge: IRemoteIngress;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the secret for a remote ingress edge.
|
||||
*/
|
||||
export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RegenerateRemoteIngressSecret
|
||||
> {
|
||||
method: 'regenerateRemoteIngressSecret';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
secret: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all remote ingress edge registrations.
|
||||
*/
|
||||
export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRemoteIngresses
|
||||
> {
|
||||
method: 'getRemoteIngresses';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
edges: IRemoteIngress[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runtime status of all remote ingress edges.
|
||||
*/
|
||||
export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRemoteIngressStatus
|
||||
> {
|
||||
method: 'getRemoteIngressStatus';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
statuses: IRemoteIngressStatus[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a connection token for a remote ingress edge.
|
||||
* The token is a single opaque base64url string that encodes hubHost, hubPort, edgeId, and secret.
|
||||
*/
|
||||
export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRemoteIngressConnectionToken
|
||||
> {
|
||||
method: 'getRemoteIngressConnectionToken';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
edgeId: string;
|
||||
hubHost?: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
146
ts_interfaces/requests/route-management.ts
Normal file
146
ts_interfaces/requests/route-management.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IMergedRoute, IRouteWarning } from '../data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all merged routes (hardcoded + programmatic) with warnings.
|
||||
*/
|
||||
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetMergedRoutes
|
||||
> {
|
||||
method: 'getMergedRoutes';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
routes: IMergedRoute[];
|
||||
warnings: IRouteWarning[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new programmatic route.
|
||||
*/
|
||||
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateRoute
|
||||
> {
|
||||
method: 'createRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
route: IRouteConfig;
|
||||
enabled?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
storedRouteId?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a programmatic route.
|
||||
*/
|
||||
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateRoute
|
||||
> {
|
||||
method: 'updateRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
route?: Partial<IRouteConfig>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a programmatic route.
|
||||
*/
|
||||
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteRoute
|
||||
> {
|
||||
method: 'deleteRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an override on a hardcoded route (disable/enable by name).
|
||||
*/
|
||||
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SetRouteOverride
|
||||
> {
|
||||
method: 'setRouteOverride';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an override from a hardcoded route (restore default behavior).
|
||||
*/
|
||||
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveRouteOverride
|
||||
> {
|
||||
method: 'removeRouteOverride';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a programmatic route on/off by id.
|
||||
*/
|
||||
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ToggleRoute
|
||||
> {
|
||||
method: 'toggleRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface IReq_GetServerStatistics extends plugins.typedrequestInterfaces
|
||||
> {
|
||||
method: 'getServerStatistics';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
includeHistory?: boolean;
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export interface IReq_GetEmailStatistics extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'getEmailStatistics';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
domain?: string;
|
||||
includeDetails?: boolean;
|
||||
@@ -49,7 +49,7 @@ export interface IReq_GetDnsStatistics extends plugins.typedrequestInterfaces.im
|
||||
> {
|
||||
method: 'getDnsStatistics';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
domain?: string;
|
||||
includeQueryTypes?: boolean;
|
||||
@@ -69,7 +69,7 @@ export interface IReq_GetRateLimitStatus extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'getRateLimitStatus';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
includeBlocked?: boolean;
|
||||
@@ -91,7 +91,7 @@ export interface IReq_GetSecurityMetrics extends plugins.typedrequestInterfaces.
|
||||
> {
|
||||
method: 'getSecurityMetrics';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
includeDetails?: boolean;
|
||||
};
|
||||
@@ -112,7 +112,7 @@ export interface IReq_GetActiveConnections extends plugins.typedrequestInterface
|
||||
> {
|
||||
method: 'getActiveConnections';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
|
||||
state?: string;
|
||||
};
|
||||
@@ -137,7 +137,7 @@ export interface IReq_GetQueueStatus extends plugins.typedrequestInterfaces.impl
|
||||
> {
|
||||
method: 'getQueueStatus';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
queueName?: string;
|
||||
};
|
||||
response: {
|
||||
@@ -153,10 +153,31 @@ export interface IReq_GetHealthStatus extends plugins.typedrequestInterfaces.imp
|
||||
> {
|
||||
method: 'getHealthStatus';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
identity: authInterfaces.IIdentity;
|
||||
detailed?: boolean;
|
||||
};
|
||||
response: {
|
||||
health: statsInterfaces.IHealthStatus;
|
||||
};
|
||||
}
|
||||
|
||||
// Network Stats (raw SmartProxy network data)
|
||||
export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetNetworkStats
|
||||
> {
|
||||
method: 'getNetworkStats';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
connectionsByIP: Array<{ ip: string; count: number }>;
|
||||
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
totalDataTransferred: { bytesIn: number; bytesOut: number };
|
||||
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
|
||||
throughputByIP: Array<{ ip: string; in: number; out: number }>;
|
||||
requestsPerSecond: number;
|
||||
requestsTotal: number;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '5.0.2',
|
||||
version: '11.2.9',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
1112
ts_web/appstate.ts
1112
ts_web/appstate.ts
File diff suppressed because it is too large
Load Diff
@@ -4,5 +4,9 @@ export * from './ops-view-network.js';
|
||||
export * from './ops-view-emails.js';
|
||||
export * from './ops-view-logs.js';
|
||||
export * from './ops-view-config.js';
|
||||
export * from './ops-view-routes.js';
|
||||
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 './shared/index.js';
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { appRouter } from '../router.js';
|
||||
|
||||
import {
|
||||
@@ -18,7 +19,11 @@ import { OpsViewNetwork } from './ops-view-network.js';
|
||||
import { OpsViewEmails } from './ops-view-emails.js';
|
||||
import { OpsViewLogs } from './ops-view-logs.js';
|
||||
import { OpsViewConfig } from './ops-view-config.js';
|
||||
import { OpsViewRoutes } from './ops-view-routes.js';
|
||||
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';
|
||||
|
||||
@customElement('ops-dashboard')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@@ -39,28 +44,54 @@ export class OpsDashboard extends DeesElement {
|
||||
private viewTabs = [
|
||||
{
|
||||
name: 'Overview',
|
||||
iconName: 'lucide:layoutDashboard',
|
||||
element: OpsViewOverview,
|
||||
},
|
||||
{
|
||||
name: 'Configuration',
|
||||
iconName: 'lucide:settings',
|
||||
element: OpsViewConfig,
|
||||
},
|
||||
{
|
||||
name: 'Network',
|
||||
iconName: 'lucide:network',
|
||||
element: OpsViewNetwork,
|
||||
},
|
||||
{
|
||||
name: 'Emails',
|
||||
iconName: 'lucide:mail',
|
||||
element: OpsViewEmails,
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
iconName: 'lucide:scrollText',
|
||||
element: OpsViewLogs,
|
||||
},
|
||||
{
|
||||
name: 'Configuration',
|
||||
element: OpsViewConfig,
|
||||
name: 'Routes',
|
||||
iconName: 'lucide:route',
|
||||
element: OpsViewRoutes,
|
||||
},
|
||||
{
|
||||
name: 'ApiTokens',
|
||||
iconName: 'lucide:key',
|
||||
element: OpsViewApiTokens,
|
||||
},
|
||||
{
|
||||
name: 'Security',
|
||||
iconName: 'lucide:shield',
|
||||
element: OpsViewSecurity,
|
||||
},
|
||||
{
|
||||
name: 'Certificates',
|
||||
iconName: 'lucide:badgeCheck',
|
||||
element: OpsViewCertificates,
|
||||
},
|
||||
{
|
||||
name: 'RemoteIngress',
|
||||
iconName: 'lucide:globe',
|
||||
element: OpsViewRemoteIngress,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -188,13 +219,27 @@ export class OpsDashboard extends DeesElement {
|
||||
// Handle initial state - check if we have a stored session that's still valid
|
||||
const loginState = appstate.loginStatePart.getState();
|
||||
if (loginState.identity?.jwt) {
|
||||
// Verify JWT hasn't expired
|
||||
if (loginState.identity.expiresAt > Date.now()) {
|
||||
// JWT still valid, restore logged-in state
|
||||
// Client-side expiry looks valid — verify with server (keypair may have changed)
|
||||
try {
|
||||
const verifyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_VerifyIdentity
|
||||
>('/typedrequest', 'verifyIdentity');
|
||||
const response = await verifyRequest.fire({ identity: loginState.identity });
|
||||
if (response.valid) {
|
||||
// JWT confirmed valid by server
|
||||
this.loginState = loginState;
|
||||
await simpleLogin.switchToSlottedContent();
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
} else {
|
||||
// Server rejected the JWT — clear state, show login
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
}
|
||||
} catch {
|
||||
// Server unreachable or error — clear state, show login
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
}
|
||||
} else {
|
||||
// JWT expired, clear the stored state
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
|
||||
348
ts_web/elements/ops-view-apitokens.ts
Normal file
348
ts_web/elements/ops-view-apitokens.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
type TApiTokenScope = interfaces.data.TApiTokenScope;
|
||||
|
||||
@customElement('ops-view-apitokens')
|
||||
export class OpsViewApiTokens extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.routeManagementStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((routeState) => {
|
||||
this.routeState = routeState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
// Re-fetch tokens when user logs in (fixes race condition where
|
||||
// the view is created before authentication completes)
|
||||
const loginSub = appstate.loginStatePart
|
||||
.select((s) => s.isLoggedIn)
|
||||
.subscribe((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.apiTokensContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.scopePill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#0369a1', '#0af')};
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.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.active {
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.statusBadge.disabled {
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.statusBadge.expired {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { apiTokens } = this.routeState;
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>API Tokens</ops-sectionheading>
|
||||
|
||||
<div class="apiTokensContainer">
|
||||
<dees-table
|
||||
.heading1=${'API Tokens'}
|
||||
.heading2=${'Manage programmatic access tokens'}
|
||||
.data=${apiTokens}
|
||||
.dataName=${'token'}
|
||||
.searchable=${true}
|
||||
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
|
||||
name: token.name,
|
||||
scopes: this.renderScopePills(token.scopes),
|
||||
status: this.renderStatusBadge(token),
|
||||
created: new Date(token.createdAt).toLocaleDateString(),
|
||||
expires: token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : 'Never',
|
||||
lastUsed: token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : 'Never',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Create Token',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
await this.showCreateTokenDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Enable',
|
||||
iconName: 'lucide:play',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.toggleApiTokenAction,
|
||||
{ id: token.id, enabled: true },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Disable',
|
||||
iconName: 'lucide:pause',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.toggleApiTokenAction,
|
||||
{ id: token.id, enabled: false },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Roll',
|
||||
iconName: 'lucide:rotateCw',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||
await this.showRollTokenDialog(token);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Revoke',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.revokeApiTokenAction,
|
||||
token.id,
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderScopePills(scopes: TApiTokenScope[]): TemplateResult {
|
||||
return html`<div style="display: flex; flex-wrap: wrap; gap: 2px;">${scopes.map(
|
||||
(s) => html`<span class="scopePill">${s}</span>`,
|
||||
)}</div>`;
|
||||
}
|
||||
|
||||
private renderStatusBadge(token: interfaces.data.IApiTokenInfo): TemplateResult {
|
||||
if (!token.enabled) {
|
||||
return html`<span class="statusBadge disabled">Disabled</span>`;
|
||||
}
|
||||
if (token.expiresAt && token.expiresAt < Date.now()) {
|
||||
return html`<span class="statusBadge expired">Expired</span>`;
|
||||
}
|
||||
return html`<span class="statusBadge active">Active</span>`;
|
||||
}
|
||||
|
||||
private async showCreateTokenDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const allScopes: TApiTokenScope[] = [
|
||||
'routes:read',
|
||||
'routes:write',
|
||||
'config:read',
|
||||
'tokens:read',
|
||||
'tokens:manage',
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Create API Token',
|
||||
content: html`
|
||||
<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
|
||||
The token value will be shown once after creation. Copy it immediately.
|
||||
</div>
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Token Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-tags
|
||||
.key=${'scopes'}
|
||||
.label=${'Token Scopes'}
|
||||
.value=${['routes:read', 'routes:write']}
|
||||
.suggestions=${allScopes}
|
||||
.required=${true}
|
||||
></dees-input-tags>
|
||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:key',
|
||||
action: async (modalArg: any) => {
|
||||
const contentEl = modalArg.shadowRoot?.querySelector('.content');
|
||||
const form = contentEl?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
if (!formData.name) return;
|
||||
|
||||
// dees-input-tags is not in dees-form's FORM_INPUT_TYPES, so collectFormData() won't
|
||||
// include it. Query the tags input directly and call getValue().
|
||||
const tagsInput = form.querySelector('dees-input-tags') as any;
|
||||
const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || [];
|
||||
const scopes = rawScopes
|
||||
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
|
||||
|
||||
const expiresInDays = formData.expiresInDays
|
||||
? parseInt(formData.expiresInDays, 10)
|
||||
: null;
|
||||
|
||||
await modalArg.destroy();
|
||||
|
||||
try {
|
||||
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays);
|
||||
if (response.success && response.tokenValue) {
|
||||
// Refresh the list first so it's ready when user dismisses the modal
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||
|
||||
// Show the token value in a new modal
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Token Created',
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Copy this token now. It will not be shown again.</p>
|
||||
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
|
||||
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Done',
|
||||
iconName: 'lucide:check',
|
||||
action: async (m: any) => await m.destroy(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create token:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Roll Token Secret',
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>This will regenerate the secret for <strong>${token.name}</strong>. The old token value will stop working immediately.</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Roll Token',
|
||||
iconName: 'lucide:rotateCw',
|
||||
action: async (modalArg: any) => {
|
||||
await modalArg.destroy();
|
||||
try {
|
||||
const response = await appstate.rollApiToken(token.id);
|
||||
if (response.success && response.tokenValue) {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Token Rolled',
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Copy this token now. It will not be shown again.</p>
|
||||
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
|
||||
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Done',
|
||||
iconName: 'lucide:check',
|
||||
action: async (m: any) => await m.destroy(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to roll token:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||
}
|
||||
}
|
||||
505
ts_web/elements/ops-view-certificates.ts
Normal file
505
ts_web/elements/ops-view-certificates.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
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-certificates': OpsViewCertificates;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-certificates')
|
||||
export class OpsViewCertificates extends DeesElement {
|
||||
@state()
|
||||
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.certificateStatePart.state.subscribe((newState) => {
|
||||
this.certState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.certificatesContainer {
|
||||
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.valid {
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.statusBadge.expiring {
|
||||
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
|
||||
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
|
||||
}
|
||||
|
||||
.statusBadge.expired,
|
||||
.statusBadge.failed {
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.statusBadge.provisioning {
|
||||
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||
}
|
||||
|
||||
.statusBadge.unknown {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
||||
}
|
||||
|
||||
.routePills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.routePill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
|
||||
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
|
||||
}
|
||||
|
||||
.moreCount {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backoffIndicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
|
||||
}
|
||||
|
||||
.expiryInfo {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.expiryInfo .daysLeft {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
.expiryInfo .daysLeft.warn {
|
||||
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
|
||||
}
|
||||
|
||||
.expiryInfo .daysLeft.danger {
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { summary } = this.certState;
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Certificates</ops-sectionheading>
|
||||
|
||||
<div class="certificatesContainer">
|
||||
${this.renderStatsTiles(summary)}
|
||||
${this.renderCertificateTable()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Certificates',
|
||||
value: summary.total,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldHalf',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'valid',
|
||||
title: 'Valid',
|
||||
value: summary.valid,
|
||||
type: 'number',
|
||||
icon: 'lucide:Check',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
id: 'expiring',
|
||||
title: 'Expiring Soon',
|
||||
value: summary.expiring,
|
||||
type: 'number',
|
||||
icon: 'lucide:Clock',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
{
|
||||
id: 'problems',
|
||||
title: 'Failed / Expired',
|
||||
value: summary.failed + summary.expired,
|
||||
type: 'number',
|
||||
icon: 'lucide:TriangleAlert',
|
||||
color: '#ef4444',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:RefreshCw',
|
||||
action: async () => {
|
||||
await appstate.certificateStatePart.dispatchAction(
|
||||
appstate.fetchCertificateOverviewAction,
|
||||
null
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCertificateTable(): TemplateResult {
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${this.certState.certificates}
|
||||
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
|
||||
Domain: cert.domain,
|
||||
Routes: this.renderRoutePills(cert.routeNames),
|
||||
Status: this.renderStatusBadge(cert.status),
|
||||
Source: this.renderSourceBadge(cert.source),
|
||||
Expires: this.renderExpiry(cert.expiryDate),
|
||||
Error: cert.backoffInfo
|
||||
? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
|
||||
: cert.error
|
||||
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
|
||||
: '',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Import Certificate',
|
||||
iconName: 'lucide:upload',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Import Certificate',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-fileupload
|
||||
key="certJsonFile"
|
||||
label="Certificate JSON (.tsclass.cert.json)"
|
||||
accept=".json"
|
||||
.multiple=${false}
|
||||
required
|
||||
></dees-input-fileupload>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Import',
|
||||
iconName: 'lucide:upload',
|
||||
action: async (modal) => {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
try {
|
||||
const form = modal.shadowRoot.querySelector('dees-form') as any;
|
||||
const formData = await form.collectFormData();
|
||||
const files = formData.certJsonFile;
|
||||
if (!files || files.length === 0) {
|
||||
DeesToast.show({ message: 'Please select a JSON file.', type: 'warning', duration: 3000 });
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
const text = await file.text();
|
||||
const cert = JSON.parse(text);
|
||||
if (!cert.domainName || !cert.publicKey || !cert.privateKey) {
|
||||
DeesToast.show({ message: 'Invalid cert JSON: missing domainName, publicKey, or privateKey.', type: 'error', duration: 4000 });
|
||||
return;
|
||||
}
|
||||
await appstate.certificateStatePart.dispatchAction(
|
||||
appstate.importCertificateAction,
|
||||
cert,
|
||||
);
|
||||
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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Reprovision',
|
||||
iconName: 'lucide:RefreshCw',
|
||||
type: ['inRow'],
|
||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||
const cert = actionData.item;
|
||||
if (!cert.canReprovision) {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({
|
||||
message: 'This certificate source does not support reprovisioning.',
|
||||
type: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await appstate.certificateStatePart.dispatchAction(
|
||||
appstate.reprovisionCertificateAction,
|
||||
cert.domain,
|
||||
);
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({
|
||||
message: `Reprovisioning triggered for ${cert.domain}`,
|
||||
type: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Export',
|
||||
iconName: 'lucide:download',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
const cert = actionData.item;
|
||||
try {
|
||||
const response = await appstate.fetchCertificateExport(cert.domain);
|
||||
if (response.success && response.cert) {
|
||||
const safeDomain = cert.domain.replace(/\*/g, '_wildcard');
|
||||
this.downloadJsonFile(`${safeDomain}.tsclass.cert.json`, response.cert);
|
||||
DeesToast.show({ message: `Certificate exported for ${cert.domain}`, type: 'success', duration: 3000 });
|
||||
} 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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||
const cert = actionData.item;
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Delete Certificate: ${cert.domain}`,
|
||||
content: html`
|
||||
<div style="padding: 20px; font-size: 14px;">
|
||||
<p>Are you sure you want to delete the certificate data for <strong>${cert.domain}</strong>?</p>
|
||||
<p style="color: #f59e0b; margin-top: 12px;">Note: The certificate may remain in proxy memory until the next restart or reprovisioning.</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modal) => {
|
||||
try {
|
||||
await appstate.certificateStatePart.dispatchAction(
|
||||
appstate.deleteCertificateAction,
|
||||
cert.domain,
|
||||
);
|
||||
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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'fa:magnifyingGlass',
|
||||
type: ['doubleClick', 'contextmenu'],
|
||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||
const cert = actionData.item;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Certificate: ${cert.domain}`,
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<dees-dataview-codebox
|
||||
.heading=${'Certificate Details'}
|
||||
progLang="json"
|
||||
.codeToDisplay=${JSON.stringify(cert, null, 2)}
|
||||
></dees-dataview-codebox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Copy Domain',
|
||||
iconName: 'lucide:Copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(cert.domain);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
heading1="Certificate Status"
|
||||
heading2="TLS certificates by domain"
|
||||
searchable
|
||||
.pagination=${true}
|
||||
.paginationSize=${50}
|
||||
dataName="certificate"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private downloadJsonFile(filename: string, data: any): void {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private renderRoutePills(routeNames: string[]): TemplateResult {
|
||||
const maxShow = 3;
|
||||
const visible = routeNames.slice(0, maxShow);
|
||||
const remaining = routeNames.length - maxShow;
|
||||
|
||||
return html`
|
||||
<span class="routePills">
|
||||
${visible.map((r) => html`<span class="routePill">${r}</span>`)}
|
||||
${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatusBadge(status: interfaces.requests.TCertificateStatus): TemplateResult {
|
||||
return html`<span class="statusBadge ${status}">${status}</span>`;
|
||||
}
|
||||
|
||||
private renderSourceBadge(source: interfaces.requests.TCertificateSource): TemplateResult {
|
||||
const labels: Record<string, string> = {
|
||||
acme: 'ACME',
|
||||
'provision-function': 'Custom',
|
||||
static: 'Static',
|
||||
none: 'None',
|
||||
};
|
||||
return html`<span class="sourceBadge">${labels[source] || source}</span>`;
|
||||
}
|
||||
|
||||
private renderExpiry(expiryDate?: string): TemplateResult {
|
||||
if (!expiryDate) {
|
||||
return html`<span style="color: ${cssManager.bdTheme('#9ca3af', '#4b5563')}">--</span>`;
|
||||
}
|
||||
|
||||
const expiry = new Date(expiryDate);
|
||||
const now = new Date();
|
||||
const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const dateStr = expiry.toLocaleDateString();
|
||||
let daysClass = '';
|
||||
let daysText = '';
|
||||
|
||||
if (daysLeft < 0) {
|
||||
daysClass = 'danger';
|
||||
daysText = `(expired)`;
|
||||
} else if (daysLeft < 30) {
|
||||
daysClass = 'warn';
|
||||
daysText = `(${daysLeft}d left)`;
|
||||
} else {
|
||||
daysText = `(${daysLeft}d left)`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<span class="expiryInfo">
|
||||
${dateStr} <span class="daysLeft ${daysClass}">${daysText}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatRetryTime(retryAfter?: string): string {
|
||||
if (!retryAfter) return 'soon';
|
||||
const retryDate = new Date(retryAfter);
|
||||
const now = new Date();
|
||||
const diffMs = retryDate.getTime() - now.getTime();
|
||||
if (diffMs <= 0) return 'now';
|
||||
const diffMin = Math.ceil(diffMs / 60000);
|
||||
if (diffMin < 60) return `in ${diffMin}m`;
|
||||
const diffHours = Math.ceil(diffMin / 60);
|
||||
return `in ${diffHours}h`;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import { appRouter } from '../router.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import type { IConfigField, IConfigSectionAction } from '@serve.zone/catalog';
|
||||
|
||||
@customElement('ops-view-config')
|
||||
export class OpsViewConfig extends DeesElement {
|
||||
@state()
|
||||
@@ -35,165 +38,19 @@ export class OpsViewConfig extends DeesElement {
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
.configSection {
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sectionTitle dees-icon {
|
||||
font-size: 20px;
|
||||
color: ${cssManager.bdTheme('#666', '#888')};
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.configField {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.configField:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
.fieldValue.empty {
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.nestedFields {
|
||||
margin-left: 16px;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
/* Status badge styles */
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusBadge.enabled {
|
||||
background: ${cssManager.bdTheme('#d4edda', '#1a3d1a')};
|
||||
color: ${cssManager.bdTheme('#155724', '#66cc66')};
|
||||
}
|
||||
|
||||
.statusBadge.disabled {
|
||||
background: ${cssManager.bdTheme('#f8d7da', '#3d1a1a')};
|
||||
color: ${cssManager.bdTheme('#721c24', '#cc6666')};
|
||||
}
|
||||
|
||||
.statusBadge dees-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Array/list display */
|
||||
.arrayItems {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.arrayItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
|
||||
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.arrayCount {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Numeric value formatting */
|
||||
.numericValue {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
background: ${cssManager.bdTheme('#fee', '#4a1f1f')};
|
||||
border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')};
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
color: ${cssManager.bdTheme('#c00', '#ff6666')};
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.loadingMessage {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.infoNote {
|
||||
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
|
||||
border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')};
|
||||
.errorMessage {
|
||||
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239,68,68,0.1)')};
|
||||
border: 1px solid ${cssManager.bdTheme('#fecaca', 'rgba(239,68,68,0.3)')};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
color: ${cssManager.bdTheme('#004085', '#88ccff')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.infoNote dees-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
margin: 16px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -202,185 +59,276 @@ export class OpsViewConfig extends DeesElement {
|
||||
return html`
|
||||
<ops-sectionheading>Configuration</ops-sectionheading>
|
||||
|
||||
${this.configState.isLoading ? html`
|
||||
${this.configState.isLoading
|
||||
? html`
|
||||
<div class="loadingMessage">
|
||||
<dees-spinner></dees-spinner>
|
||||
<p>Loading configuration...</p>
|
||||
</div>
|
||||
` : this.configState.error ? html`
|
||||
`
|
||||
: this.configState.error
|
||||
? html`
|
||||
<div class="errorMessage">
|
||||
Error loading configuration: ${this.configState.error}
|
||||
</div>
|
||||
` : this.configState.config ? html`
|
||||
<div class="infoNote">
|
||||
<dees-icon icon="lucide:info"></dees-icon>
|
||||
<span>This view displays the current running configuration. DcRouter is configured through code or remote management.</span>
|
||||
</div>
|
||||
|
||||
${this.renderConfigSection('email', 'Email', 'lucide:mail', this.configState.config?.email)}
|
||||
${this.renderConfigSection('dns', 'DNS', 'lucide:globe', this.configState.config?.dns)}
|
||||
${this.renderConfigSection('proxy', 'Proxy', 'lucide:network', this.configState.config?.proxy)}
|
||||
${this.renderConfigSection('security', 'Security', 'lucide:shield', this.configState.config?.security)}
|
||||
` : html`
|
||||
<div class="errorMessage">No configuration loaded</div>
|
||||
`}
|
||||
`
|
||||
: this.configState.config
|
||||
? this.renderConfig()
|
||||
: html`<div class="errorMessage">No configuration loaded</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConfigSection(key: string, title: string, icon: string, config: any) {
|
||||
const isEnabled = config?.enabled ?? false;
|
||||
private renderConfig(): TemplateResult {
|
||||
const cfg = this.configState.config!;
|
||||
|
||||
return html`
|
||||
<div class="configSection">
|
||||
<div class="sectionHeader">
|
||||
<h3 class="sectionTitle">
|
||||
<dees-icon icon="${icon}"></dees-icon>
|
||||
${title}
|
||||
</h3>
|
||||
${this.renderStatusBadge(isEnabled)}
|
||||
</div>
|
||||
<div class="sectionContent">
|
||||
${config ? this.renderConfigFields(config) : html`
|
||||
<div class="fieldValue empty">Not configured</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<sz-config-overview
|
||||
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
|
||||
@navigate=${(e: CustomEvent) => {
|
||||
if (e.detail?.view) {
|
||||
appRouter.navigateToView(e.detail.view);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${this.renderSystemSection(cfg.system)}
|
||||
${this.renderSmartProxySection(cfg.smartProxy)}
|
||||
${this.renderEmailSection(cfg.email)}
|
||||
${this.renderDnsSection(cfg.dns)}
|
||||
${this.renderTlsSection(cfg.tls)}
|
||||
${this.renderCacheSection(cfg.cache)}
|
||||
${this.renderRadiusSection(cfg.radius)}
|
||||
${this.renderRemoteIngressSection(cfg.remoteIngress)}
|
||||
</sz-config-overview>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatusBadge(enabled: boolean): TemplateResult {
|
||||
return enabled
|
||||
? html`<span class="statusBadge enabled"><dees-icon icon="lucide:check"></dees-icon>Enabled</span>`
|
||||
: html`<span class="statusBadge disabled"><dees-icon icon="lucide:x"></dees-icon>Disabled</span>`;
|
||||
private renderSystemSection(sys: 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;
|
||||
if (proxyIpValues && ri?.enabled && proxyIpValues.includes('127.0.0.1')) {
|
||||
proxyIpValues = proxyIpValues.map(ip =>
|
||||
ip === '127.0.0.1' ? '127.0.0.1 (Remote Ingress)' : ip
|
||||
);
|
||||
}
|
||||
|
||||
private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return html`<div class="fieldValue">${this.formatValue(config)}</div>`;
|
||||
}
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Base Directory', value: sys.baseDir },
|
||||
{ key: 'Data Directory', value: sys.dataDir },
|
||||
{ key: 'Public IP', value: sys.publicIp },
|
||||
{ key: 'Proxy IPs', value: proxyIpValues, type: 'pills' },
|
||||
{ key: 'Uptime', value: this.formatUptime(sys.uptime) },
|
||||
{ key: 'Storage Backend', value: sys.storageBackend, type: 'badge' },
|
||||
{ key: 'Storage Path', value: sys.storagePath },
|
||||
];
|
||||
|
||||
return Object.entries(config).map(([key, value]) => {
|
||||
const fieldName = prefix ? `${prefix}.${key}` : key;
|
||||
const displayName = this.formatFieldName(key);
|
||||
|
||||
// Handle boolean values with badges
|
||||
if (typeof value === 'boolean') {
|
||||
return html`
|
||||
<div class="configField">
|
||||
<label class="fieldLabel">${displayName}</label>
|
||||
${this.renderStatusBadge(value)}
|
||||
</div>
|
||||
<sz-config-section
|
||||
title="System"
|
||||
subtitle="Base paths and infrastructure"
|
||||
icon="lucide:server"
|
||||
status="enabled"
|
||||
.fields=${fields}
|
||||
></sz-config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Route Count', value: proxy.routeCount },
|
||||
];
|
||||
|
||||
if (proxy.acme) {
|
||||
fields.push(
|
||||
{ key: 'ACME Enabled', value: proxy.acme.enabled, type: 'boolean' },
|
||||
{ key: 'Account Email', value: proxy.acme.accountEmail || null },
|
||||
{ key: 'Use Production', value: proxy.acme.useProduction, type: 'boolean' },
|
||||
{ key: 'Auto Renew', value: proxy.acme.autoRenew, type: 'boolean' },
|
||||
{ key: 'Renew Threshold', value: `${proxy.acme.renewThresholdDays} days` },
|
||||
);
|
||||
}
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="configField">
|
||||
<label class="fieldLabel">${displayName}</label>
|
||||
${this.renderArrayValue(value, key)}
|
||||
</div>
|
||||
<sz-config-section
|
||||
title="SmartProxy"
|
||||
subtitle="HTTP/HTTPS and TCP/SNI reverse proxy"
|
||||
icon="lucide:network"
|
||||
.status=${proxy.enabled ? 'enabled' : 'disabled'}
|
||||
.fields=${fields}
|
||||
.actions=${actions}
|
||||
></sz-config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Handle nested objects
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
private renderEmailSection(email: 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 },
|
||||
{ key: 'Domains', value: email.domains.length > 0 ? email.domains : null, type: 'pills' },
|
||||
{ key: 'Email Routes', value: email.emailRouteCount },
|
||||
{ key: 'Received Emails Path', value: email.receivedEmailsPath },
|
||||
];
|
||||
|
||||
if (email.portMapping) {
|
||||
const mappingStr = Object.entries(email.portMapping)
|
||||
.map(([ext, int]) => `${ext} → ${int}`)
|
||||
.join(', ');
|
||||
fields.splice(1, 0, { key: 'Port Mapping', value: mappingStr, type: 'code' });
|
||||
}
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="configField">
|
||||
<label class="fieldLabel">${displayName}</label>
|
||||
<div class="nestedFields">
|
||||
${this.renderConfigFields(value, fieldName)}
|
||||
</div>
|
||||
</div>
|
||||
<sz-config-section
|
||||
title="Email Server"
|
||||
subtitle="SMTP email handling with smartmta"
|
||||
icon="lucide:mail"
|
||||
.status=${email.enabled ? 'enabled' : 'disabled'}
|
||||
.fields=${fields}
|
||||
.actions=${actions}
|
||||
></sz-config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Handle primitive values
|
||||
private renderDnsSection(dns: 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' },
|
||||
{ key: 'Scopes', value: dns.scopes.length > 0 ? dns.scopes : null, type: 'pills' },
|
||||
{ key: 'Record Count', value: dns.recordCount },
|
||||
{ key: 'DNS Challenge', value: dns.dnsChallenge, type: 'boolean' },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="configField">
|
||||
<label class="fieldLabel">${displayName}</label>
|
||||
<div class="fieldValue">${this.formatValue(value, key)}</div>
|
||||
</div>
|
||||
<sz-config-section
|
||||
title="DNS Server"
|
||||
subtitle="Authoritative DNS with smartdns"
|
||||
icon="lucide:globe"
|
||||
.status=${dns.enabled ? 'enabled' : 'disabled'}
|
||||
.fields=${fields}
|
||||
></sz-config-section>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
private renderArrayValue(arr: any[], fieldKey: string): TemplateResult {
|
||||
if (arr.length === 0) {
|
||||
return html`<div class="fieldValue empty">None configured</div>`;
|
||||
}
|
||||
private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Contact Email', value: tls.contactEmail },
|
||||
{ key: 'Domain', value: tls.domain },
|
||||
{ key: 'Source', value: tls.source, type: 'badge' },
|
||||
{ key: 'Certificate Path', value: tls.certPath },
|
||||
{ key: 'Key Path', value: tls.keyPath },
|
||||
];
|
||||
|
||||
// Determine if we should show as pills/tags
|
||||
const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number');
|
||||
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
|
||||
];
|
||||
|
||||
if (showAsPills) {
|
||||
const itemLabel = this.getArrayItemLabel(fieldKey, arr.length);
|
||||
return html`
|
||||
<div class="arrayCount">${arr.length} ${itemLabel}</div>
|
||||
<div class="arrayItems">
|
||||
${arr.map(item => html`<span class="arrayItem">${item}</span>`)}
|
||||
</div>
|
||||
<sz-config-section
|
||||
title="TLS / Certificates"
|
||||
subtitle="Certificate management and ACME"
|
||||
icon="lucide:shield-check"
|
||||
.status=${status as any}
|
||||
.fields=${fields}
|
||||
.actions=${actions}
|
||||
></sz-config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
// For complex arrays, show as JSON
|
||||
private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Storage Path', value: cache.storagePath },
|
||||
{ key: 'DB Name', value: cache.dbName },
|
||||
{ key: 'Default TTL', value: `${cache.defaultTTLDays} days` },
|
||||
{ key: 'Cleanup Interval', value: `${cache.cleanupIntervalHours} hours` },
|
||||
];
|
||||
|
||||
if (cache.ttlConfig && Object.keys(cache.ttlConfig).length > 0) {
|
||||
for (const [key, val] of Object.entries(cache.ttlConfig)) {
|
||||
fields.push({ key: `TTL: ${key}`, value: `${val} days` });
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fieldValue">
|
||||
${arr.length} items configured
|
||||
</div>
|
||||
<sz-config-section
|
||||
title="Cache Database"
|
||||
subtitle="Persistent caching with smartdata"
|
||||
icon="lucide:database"
|
||||
.status=${cache.enabled ? 'enabled' : 'disabled'}
|
||||
.fields=${fields}
|
||||
></sz-config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
private getArrayItemLabel(fieldKey: string, count: number): string {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
ports: ['port', 'ports'],
|
||||
domains: ['domain', 'domains'],
|
||||
nameservers: ['nameserver', 'nameservers'],
|
||||
blockList: ['IP', 'IPs'],
|
||||
};
|
||||
private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Auth Port', value: radius.authPort },
|
||||
{ key: 'Accounting Port', value: radius.acctPort },
|
||||
{ key: 'Bind Address', value: radius.bindAddress },
|
||||
{ key: 'Client Count', value: radius.clientCount },
|
||||
];
|
||||
|
||||
const label = labels[fieldKey] || ['item', 'items'];
|
||||
return count === 1 ? label[0] : label[1];
|
||||
if (radius.vlanDefaultVlan !== null) {
|
||||
fields.push(
|
||||
{ key: 'Default VLAN', value: radius.vlanDefaultVlan },
|
||||
{ key: 'Allow Unknown MACs', value: radius.vlanAllowUnknownMacs, type: 'boolean' },
|
||||
{ key: 'VLAN Mappings', value: radius.vlanMappingCount },
|
||||
);
|
||||
}
|
||||
|
||||
private formatFieldName(key: string): string {
|
||||
// Convert camelCase to readable format
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, str => str.toUpperCase())
|
||||
.trim();
|
||||
const status = radius.enabled ? 'enabled' : 'not-configured';
|
||||
|
||||
return html`
|
||||
<sz-config-section
|
||||
title="RADIUS Server"
|
||||
subtitle="Network authentication and VLAN assignment"
|
||||
icon="lucide:wifi"
|
||||
.status=${status as any}
|
||||
.fields=${fields}
|
||||
></sz-config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatValue(value: any, fieldKey?: string): string | TemplateResult {
|
||||
if (value === null || value === undefined) {
|
||||
return html`<span class="empty">Not set</span>`;
|
||||
private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult {
|
||||
const fields: IConfigField[] = [
|
||||
{ key: 'Tunnel Port', value: ri.tunnelPort },
|
||||
{ key: 'Hub Domain', value: ri.hubDomain },
|
||||
{ key: 'TLS Mode', value: ri.tlsMode, type: 'badge' },
|
||||
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
|
||||
];
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
<sz-config-section
|
||||
title="Remote Ingress"
|
||||
subtitle="Edge tunnel nodes"
|
||||
icon="lucide:cloud"
|
||||
.status=${ri.enabled ? 'enabled' : 'disabled'}
|
||||
.fields=${fields}
|
||||
.actions=${actions}
|
||||
></sz-config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
// Format bytes
|
||||
if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) {
|
||||
return html`<span class="numericValue">${this.formatBytes(value)}</span>`;
|
||||
}
|
||||
// Format time values
|
||||
if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) {
|
||||
return html`<span class="numericValue">${value} seconds</span>`;
|
||||
}
|
||||
// Format port numbers
|
||||
if (fieldKey?.toLowerCase().includes('port')) {
|
||||
return html`<span class="numericValue">${value}</span>`;
|
||||
}
|
||||
// Format counts with separators
|
||||
return html`<span class="numericValue">${value.toLocaleString()}</span>`;
|
||||
}
|
||||
private formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
parts.push(`${mins}m`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { appRouter } from '../router.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -10,67 +10,30 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
type TEmailFolder = 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
||||
|
||||
@customElement('ops-view-emails')
|
||||
export class OpsViewEmails extends DeesElement {
|
||||
@state()
|
||||
accessor selectedFolder: TEmailFolder = 'queued';
|
||||
accessor emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
@state()
|
||||
accessor queuedEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
accessor selectedEmail: interfaces.requests.IEmailDetail | null = null;
|
||||
|
||||
@state()
|
||||
accessor sentEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
@state()
|
||||
accessor failedEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
@state()
|
||||
accessor securityIncidents: interfaces.requests.ISecurityIncident[] = [];
|
||||
|
||||
@state()
|
||||
accessor selectedEmail: interfaces.requests.IEmailQueueItem | null = null;
|
||||
|
||||
@state()
|
||||
accessor selectedIncident: interfaces.requests.ISecurityIncident | null = null;
|
||||
|
||||
@state()
|
||||
accessor showCompose = false;
|
||||
accessor currentView: 'list' | 'detail' = 'list';
|
||||
|
||||
@state()
|
||||
accessor isLoading = false;
|
||||
|
||||
@state()
|
||||
accessor searchTerm = '';
|
||||
|
||||
@state()
|
||||
accessor emailDomains: string[] = [];
|
||||
|
||||
private stateSubscription: any;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.loadData();
|
||||
this.loadEmailDomains();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
// Subscribe to state changes
|
||||
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
|
||||
this.queuedEmails = state.queuedEmails;
|
||||
this.sentEmails = state.sentEmails;
|
||||
this.failedEmails = state.failedEmails;
|
||||
this.securityIncidents = state.securityIncidents;
|
||||
this.emails = state.emails;
|
||||
this.isLoading = state.isLoading;
|
||||
|
||||
// Sync folder from state (e.g., when URL changes)
|
||||
if (state.currentView !== this.selectedFolder) {
|
||||
this.selectedFolder = state.currentView as TEmailFolder;
|
||||
this.loadFolderData(state.currentView as TEmailFolder);
|
||||
}
|
||||
});
|
||||
// Initial fetch
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchAllEmailsAction, null);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
@@ -89,730 +52,58 @@ export class OpsViewEmails extends DeesElement {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.emailLayout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
.viewContainer {
|
||||
height: 100%;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.mainArea {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.emailList {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailPreview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailHeader {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
.emailSubject {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
.emailMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.emailMetaRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emailMetaLabel {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.emailBody {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.emailActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.status-delivered {
|
||||
color: ${cssManager.bdTheme('#10b981', '#34d399')};
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
|
||||
}
|
||||
|
||||
.status-deferred {
|
||||
color: ${cssManager.bdTheme('#f97316', '#fb923c')};
|
||||
}
|
||||
|
||||
.severity-info {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.severity-warn {
|
||||
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
||||
}
|
||||
|
||||
.severity-error {
|
||||
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.incidentDetails {
|
||||
padding: 24px;
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.incidentHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.incidentTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.incidentMeta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.incidentField {
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.incidentFieldLabel {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.incidentFieldValue {
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
if (this.selectedEmail) {
|
||||
return this.renderEmailDetail();
|
||||
}
|
||||
|
||||
if (this.selectedIncident) {
|
||||
return this.renderIncidentDetail();
|
||||
}
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Email Operations</ops-sectionheading>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="emailToolbar" style="margin-bottom: 16px;">
|
||||
<dees-button @click=${() => this.openComposeModal()} type="highlighted">
|
||||
<dees-icon icon="lucide:penLine" slot="iconSlot"></dees-icon>
|
||||
Compose
|
||||
</dees-button>
|
||||
|
||||
<dees-input-text
|
||||
class="searchBox"
|
||||
placeholder="Search..."
|
||||
.value=${this.searchTerm}
|
||||
@input=${(e: Event) => this.searchTerm = (e.target as any).value}
|
||||
>
|
||||
<dees-icon icon="lucide:search" slot="iconSlot"></dees-icon>
|
||||
</dees-input-text>
|
||||
|
||||
<dees-button @click=${() => this.refreshData()}>
|
||||
${this.isLoading ? html`<dees-spinner slot="iconSlot" size="small"></dees-spinner>` : html`<dees-icon slot="iconSlot" icon="lucide:refreshCw"></dees-icon>`}
|
||||
Refresh
|
||||
</dees-button>
|
||||
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<dees-button-group>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('queued')}
|
||||
.type=${this.selectedFolder === 'queued' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Queued ${this.queuedEmails.length > 0 ? `(${this.queuedEmails.length})` : ''}
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('sent')}
|
||||
.type=${this.selectedFolder === 'sent' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Sent
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('failed')}
|
||||
.type=${this.selectedFolder === 'failed' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Failed ${this.failedEmails.length > 0 ? `(${this.failedEmails.length})` : ''}
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('security')}
|
||||
.type=${this.selectedFolder === 'security' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Security ${this.securityIncidents.length > 0 ? `(${this.securityIncidents.length})` : ''}
|
||||
</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.renderContent()}
|
||||
`;
|
||||
<div class="viewContainer">
|
||||
${this.currentView === 'detail' && this.selectedEmail
|
||||
? html`
|
||||
<sz-mta-detail-view
|
||||
.email=${this.selectedEmail}
|
||||
@back=${this.handleBack}
|
||||
></sz-mta-detail-view>
|
||||
`
|
||||
: html`
|
||||
<sz-mta-list-view
|
||||
.emails=${this.emails}
|
||||
@email-click=${this.handleEmailClick}
|
||||
></sz-mta-list-view>
|
||||
`
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
switch (this.selectedFolder) {
|
||||
case 'queued':
|
||||
return this.renderEmailTable(this.queuedEmails, 'Queued Emails', 'Emails waiting to be delivered');
|
||||
case 'sent':
|
||||
return this.renderEmailTable(this.sentEmails, 'Sent Emails', 'Successfully delivered emails');
|
||||
case 'failed':
|
||||
return this.renderEmailTable(this.failedEmails, 'Failed Emails', 'Emails that failed to deliver', true);
|
||||
case 'security':
|
||||
return this.renderSecurityIncidents();
|
||||
default:
|
||||
return this.renderEmptyState('Select a folder');
|
||||
}
|
||||
}
|
||||
|
||||
private renderEmailTable(
|
||||
emails: interfaces.requests.IEmailQueueItem[],
|
||||
heading1: string,
|
||||
heading2: string,
|
||||
showResend = false
|
||||
) {
|
||||
const filteredEmails = this.filterEmails(emails);
|
||||
|
||||
if (filteredEmails.length === 0) {
|
||||
return this.renderEmptyState(`No emails in ${this.selectedFolder}`);
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:eye',
|
||||
type: ['doubleClick', 'inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
this.selectedEmail = actionData.item;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (showResend) {
|
||||
actions.push({
|
||||
name: 'Resend',
|
||||
iconName: 'lucide:send',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.resendEmail(actionData.item.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${filteredEmails}
|
||||
.displayFunction=${(email: interfaces.requests.IEmailQueueItem) => ({
|
||||
'Status': html`<span class="status-${email.status}">${email.status}</span>`,
|
||||
'From': email.from || 'N/A',
|
||||
'To': email.to?.join(', ') || 'N/A',
|
||||
'Subject': email.subject || 'No subject',
|
||||
'Attempts': email.attempts,
|
||||
'Created': this.formatDate(email.createdAt),
|
||||
})}
|
||||
.dataActions=${actions}
|
||||
.selectionMode=${'single'}
|
||||
heading1=${heading1}
|
||||
heading2=${`${filteredEmails.length} emails - ${heading2}`}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSecurityIncidents() {
|
||||
const incidents = this.securityIncidents;
|
||||
|
||||
if (incidents.length === 0) {
|
||||
return this.renderEmptyState('No security incidents');
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${incidents}
|
||||
.displayFunction=${(incident: interfaces.requests.ISecurityIncident) => ({
|
||||
'Severity': html`<span class="severity-${incident.level}">${incident.level.toUpperCase()}</span>`,
|
||||
'Type': incident.type,
|
||||
'Message': incident.message,
|
||||
'IP': incident.ipAddress || 'N/A',
|
||||
'Domain': incident.domain || 'N/A',
|
||||
'Time': this.formatDate(incident.timestamp),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:eye',
|
||||
type: ['doubleClick', 'inRow'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
this.selectedIncident = actionData.item;
|
||||
}
|
||||
}
|
||||
]}
|
||||
.selectionMode=${'single'}
|
||||
heading1="Security Incidents"
|
||||
heading2=${`${incidents.length} incidents`}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmailDetail() {
|
||||
if (!this.selectedEmail) return '';
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Email Details</ops-sectionheading>
|
||||
<div class="emailLayout">
|
||||
<div class="sidebar">
|
||||
<dees-windowbox>
|
||||
<dees-button @click=${() => this.selectedEmail = null} type="secondary" style="width: 100%;">
|
||||
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
|
||||
Back to List
|
||||
</dees-button>
|
||||
</dees-windowbox>
|
||||
</div>
|
||||
<div class="mainArea">
|
||||
<div class="emailPreview">
|
||||
<div class="emailHeader">
|
||||
<div class="emailSubject">${this.selectedEmail.subject || 'No subject'}</div>
|
||||
<div class="emailMeta">
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Status:</span>
|
||||
<span class="status-${this.selectedEmail.status}">${this.selectedEmail.status}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">From:</span>
|
||||
<span>${this.selectedEmail.from || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">To:</span>
|
||||
<span>${this.selectedEmail.to?.join(', ') || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Mode:</span>
|
||||
<span>${this.selectedEmail.processingMode}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Attempts:</span>
|
||||
<span>${this.selectedEmail.attempts}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Created:</span>
|
||||
<span>${new Date(this.selectedEmail.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
${this.selectedEmail.deliveredAt ? html`
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Delivered:</span>
|
||||
<span>${new Date(this.selectedEmail.deliveredAt).toLocaleString()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${this.selectedEmail.lastError ? html`
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Last Error:</span>
|
||||
<span style="color: #ef4444;">${this.selectedEmail.lastError}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="emailActions">
|
||||
${this.selectedEmail.status === 'failed' ? html`
|
||||
<dees-button @click=${() => this.resendEmail(this.selectedEmail!.id)} type="highlighted">
|
||||
<dees-icon icon="lucide:send" slot="iconSlot"></dees-icon>
|
||||
Resend
|
||||
</dees-button>
|
||||
` : ''}
|
||||
<dees-button @click=${() => this.selectedEmail = null}>
|
||||
<dees-icon icon="lucide:x" slot="iconSlot"></dees-icon>
|
||||
Close
|
||||
</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIncidentDetail() {
|
||||
if (!this.selectedIncident) return '';
|
||||
|
||||
const incident = this.selectedIncident;
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Security Incident Details</ops-sectionheading>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<dees-button @click=${() => this.selectedIncident = null} type="secondary">
|
||||
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
|
||||
Back to List
|
||||
</dees-button>
|
||||
</div>
|
||||
<div class="incidentDetails">
|
||||
<div class="incidentHeader">
|
||||
<div>
|
||||
<div class="incidentTitle">${incident.message}</div>
|
||||
<div style="margin-top: 8px; color: #666;">
|
||||
${new Date(incident.timestamp).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span class="severity-${incident.level}" style="font-size: 16px; padding: 4px 12px; background: rgba(0,0,0,0.1); border-radius: 4px;">
|
||||
${incident.level.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="incidentMeta">
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Type</div>
|
||||
<div class="incidentFieldValue">${incident.type}</div>
|
||||
</div>
|
||||
${incident.ipAddress ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">IP Address</div>
|
||||
<div class="incidentFieldValue">${incident.ipAddress}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.domain ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Domain</div>
|
||||
<div class="incidentFieldValue">${incident.domain}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.emailId ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Email ID</div>
|
||||
<div class="incidentFieldValue">${incident.emailId}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.userId ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">User ID</div>
|
||||
<div class="incidentFieldValue">${incident.userId}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.action ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Action</div>
|
||||
<div class="incidentFieldValue">${incident.action}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.result ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Result</div>
|
||||
<div class="incidentFieldValue">${incident.result}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.success !== undefined ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Success</div>
|
||||
<div class="incidentFieldValue">${incident.success ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${incident.details ? html`
|
||||
<div style="margin-top: 24px;">
|
||||
<div class="incidentFieldLabel" style="margin-bottom: 8px;">Details</div>
|
||||
<pre style="background: #1a1a1a; color: #e5e5e5; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px;">
|
||||
${JSON.stringify(incident.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmptyState(message: string) {
|
||||
return html`
|
||||
<div class="emptyState">
|
||||
<dees-icon class="emptyIcon" icon="lucide:inbox"></dees-icon>
|
||||
<div class="emptyText">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async openComposeModal() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
// Ensure domains are loaded before opening modal
|
||||
if (this.emailDomains.length === 0) {
|
||||
await this.loadEmailDomains();
|
||||
}
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'New Email',
|
||||
width: 'large',
|
||||
content: html`
|
||||
<div>
|
||||
<dees-form @formData=${async (e: CustomEvent) => {
|
||||
await this.sendEmail(e.detail);
|
||||
const modals = document.querySelectorAll('dees-modal');
|
||||
modals.forEach(m => (m as any).destroy?.());
|
||||
}}>
|
||||
<div style="display: flex; gap: 8px; align-items: flex-end;">
|
||||
<dees-input-text
|
||||
key="fromUsername"
|
||||
label="From"
|
||||
placeholder="username"
|
||||
.value=${'admin'}
|
||||
required
|
||||
style="flex: 1;"
|
||||
></dees-input-text>
|
||||
<span style="padding-bottom: 12px; font-size: 18px; color: #666;">@</span>
|
||||
<dees-input-dropdown
|
||||
key="fromDomain"
|
||||
label=" "
|
||||
.options=${this.emailDomains.length > 0
|
||||
? this.emailDomains.map(domain => ({ key: domain, value: domain }))
|
||||
: [{ key: 'dcrouter.local', value: 'dcrouter.local' }]}
|
||||
.selectedKey=${this.emailDomains[0] || 'dcrouter.local'}
|
||||
required
|
||||
style="flex: 1;"
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<dees-input-tags
|
||||
key="to"
|
||||
label="To"
|
||||
placeholder="Enter recipient email addresses..."
|
||||
required
|
||||
></dees-input-tags>
|
||||
|
||||
<dees-input-tags
|
||||
key="cc"
|
||||
label="CC"
|
||||
placeholder="Enter CC recipients..."
|
||||
></dees-input-tags>
|
||||
|
||||
<dees-input-text
|
||||
key="subject"
|
||||
label="Subject"
|
||||
placeholder="Enter email subject..."
|
||||
required
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-wysiwyg
|
||||
key="body"
|
||||
label="Message"
|
||||
outputFormat="html"
|
||||
></dees-input-wysiwyg>
|
||||
</dees-form>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Send',
|
||||
iconName: 'lucide:send',
|
||||
action: async (modalArg) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('dees-form') as any;
|
||||
form?.submit();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg) => await modalArg.destroy()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private filterEmails(emails: interfaces.requests.IEmailQueueItem[]): interfaces.requests.IEmailQueueItem[] {
|
||||
if (!this.searchTerm) {
|
||||
return emails;
|
||||
}
|
||||
|
||||
const search = this.searchTerm.toLowerCase();
|
||||
return emails.filter(e =>
|
||||
(e.subject?.toLowerCase().includes(search)) ||
|
||||
(e.from?.toLowerCase().includes(search)) ||
|
||||
(e.to?.some(t => t.toLowerCase().includes(search)))
|
||||
);
|
||||
}
|
||||
|
||||
private selectFolder(folder: TEmailFolder) {
|
||||
// Use router for navigation to update URL
|
||||
appRouter.navigateToEmailFolder(folder);
|
||||
// Clear selections
|
||||
this.selectedEmail = null;
|
||||
this.selectedIncident = null;
|
||||
}
|
||||
|
||||
private formatDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
if (hours < 24) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (hours < 168) { // 7 days
|
||||
return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
this.isLoading = true;
|
||||
await this.loadFolderData(this.selectedFolder);
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private async loadFolderData(folder: TEmailFolder) {
|
||||
switch (folder) {
|
||||
case 'queued':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchQueuedEmailsAction, null);
|
||||
break;
|
||||
case 'sent':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSentEmailsAction, null);
|
||||
break;
|
||||
case 'failed':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchFailedEmailsAction, null);
|
||||
break;
|
||||
case 'security':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSecurityIncidentsAction, null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEmailDomains() {
|
||||
private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
|
||||
const emailSummary = e.detail;
|
||||
try {
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
const config = appstate.configStatePart.getState().config;
|
||||
const context = appstate.loginStatePart.getState();
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailDetail
|
||||
>('/typedrequest', 'getEmailDetail');
|
||||
|
||||
if (config?.email?.domains && Array.isArray(config.email.domains) && config.email.domains.length > 0) {
|
||||
this.emailDomains = config.email.domains;
|
||||
} else {
|
||||
this.emailDomains = ['dcrouter.local'];
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
emailId: emailSummary.id,
|
||||
});
|
||||
|
||||
if (response.email) {
|
||||
this.selectedEmail = response.email;
|
||||
this.currentView = 'detail';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load email domains:', error);
|
||||
this.emailDomains = ['dcrouter.local'];
|
||||
console.error('Failed to fetch email detail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshData() {
|
||||
this.isLoading = true;
|
||||
await this.loadFolderData(this.selectedFolder);
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private async sendEmail(formData: any) {
|
||||
try {
|
||||
console.log('Sending email:', formData);
|
||||
// TODO: Implement actual email sending via API
|
||||
// For now, just log the data
|
||||
const fromEmail = `${formData.fromUsername || 'admin'}@${formData.fromDomain || this.emailDomains[0] || 'dcrouter.local'}`;
|
||||
console.log('From:', fromEmail);
|
||||
console.log('To:', formData.to);
|
||||
console.log('Subject:', formData.subject);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send email', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async resendEmail(emailId: string) {
|
||||
try {
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.resendEmailAction, emailId);
|
||||
private handleBack() {
|
||||
this.selectedEmail = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to resend email:', error);
|
||||
}
|
||||
this.currentView = 'list';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
|
||||
@@ -20,6 +19,8 @@ export class OpsViewLogs extends DeesElement {
|
||||
filters: {},
|
||||
};
|
||||
|
||||
private lastPushedCount = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.logStatePart
|
||||
@@ -33,175 +34,83 @@ export class OpsViewLogs extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logContainer {
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1e1e1e')};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logTimestamp {
|
||||
color: ${cssManager.bdTheme('#7a7a7a', '#7a7a7a')};
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.logLevel {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.logLevel.debug {
|
||||
color: ${cssManager.bdTheme('#6a9955', '#6a9955')};
|
||||
background: ${cssManager.bdTheme('rgba(106, 153, 85, 0.1)', 'rgba(106, 153, 85, 0.1)')};
|
||||
}
|
||||
.logLevel.info {
|
||||
color: ${cssManager.bdTheme('#569cd6', '#569cd6')};
|
||||
background: ${cssManager.bdTheme('rgba(86, 156, 214, 0.1)', 'rgba(86, 156, 214, 0.1)')};
|
||||
}
|
||||
.logLevel.warn {
|
||||
color: ${cssManager.bdTheme('#ce9178', '#ce9178')};
|
||||
background: ${cssManager.bdTheme('rgba(206, 145, 120, 0.1)', 'rgba(206, 145, 120, 0.1)')};
|
||||
}
|
||||
.logLevel.error {
|
||||
color: ${cssManager.bdTheme('#f44747', '#f44747')};
|
||||
background: ${cssManager.bdTheme('rgba(244, 71, 71, 0.1)', 'rgba(244, 71, 71, 0.1)')};
|
||||
}
|
||||
|
||||
.logCategory {
|
||||
color: ${cssManager.bdTheme('#c586c0', '#c586c0')};
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.logMessage {
|
||||
color: ${cssManager.bdTheme('#333', '#d4d4d4')};
|
||||
}
|
||||
|
||||
.noLogs {
|
||||
color: ${cssManager.bdTheme('#7a7a7a', '#7a7a7a')};
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
`,
|
||||
css``,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<ops-sectionheading>Logs</ops-sectionheading>
|
||||
|
||||
<div class="controls">
|
||||
<div class="filterGroup">
|
||||
<dees-button
|
||||
@click=${() => this.fetchLogs()}
|
||||
>
|
||||
Refresh Logs
|
||||
</dees-button>
|
||||
|
||||
<dees-button
|
||||
@click=${() => this.toggleStreaming()}
|
||||
.type=${this.logState.isStreaming ? 'highlighted' : 'normal'}
|
||||
>
|
||||
${this.logState.isStreaming ? 'Stop Streaming' : 'Start Streaming'}
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Level:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['all', 'debug', 'info', 'warn', 'error']}
|
||||
.selectedOption=${'all'}
|
||||
@selectedOption=${(e) => this.updateFilter('level', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Category:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['all', 'smtp', 'dns', 'security', 'system', 'email']}
|
||||
.selectedOption=${'all'}
|
||||
@selectedOption=${(e) => this.updateFilter('category', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Limit:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['50', '100', '200', '500']}
|
||||
.selectedOption=${'100'}
|
||||
@selectedOption=${(e) => this.updateFilter('limit', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logContainer">
|
||||
${this.logState.recentLogs.length > 0 ?
|
||||
this.logState.recentLogs.map(log => html`
|
||||
<div class="logEntry">
|
||||
<span class="logTimestamp">${new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||
<span class="logLevel ${log.level}">${log.level.toUpperCase()}</span>
|
||||
<span class="logCategory">[${log.category}]</span>
|
||||
<span class="logMessage">${log.message}</span>
|
||||
</div>
|
||||
`) : html`
|
||||
<div class="noLogs">No logs to display</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<dees-chart-log
|
||||
.label=${'Application Logs'}
|
||||
.autoScroll=${true}
|
||||
.maxEntries=${2000}
|
||||
.showMetrics=${true}
|
||||
></dees-chart-log>
|
||||
`;
|
||||
}
|
||||
|
||||
private async fetchLogs() {
|
||||
const filters = this.getActiveFilters();
|
||||
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, {
|
||||
limit: filters.limit || 100,
|
||||
level: filters.level as 'debug' | 'info' | 'warn' | 'error' | undefined,
|
||||
category: filters.category as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined,
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.lastPushedCount = 0;
|
||||
// Only fetch if state is empty (streaming will handle new entries)
|
||||
if (this.logState.recentLogs.length === 0) {
|
||||
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('logState')) {
|
||||
this.pushLogsToChart();
|
||||
}
|
||||
}
|
||||
|
||||
private async pushLogsToChart() {
|
||||
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
|
||||
if (!chartLog) return;
|
||||
|
||||
// Ensure the chart element has finished its own initialization
|
||||
await chartLog.updateComplete;
|
||||
|
||||
// Wait for xterm terminal to finish initializing (CDN load)
|
||||
if (!chartLog.terminalReady) {
|
||||
await new Promise<void>((resolve) => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 200; // 200 * 50ms = 10 seconds
|
||||
const check = () => {
|
||||
if (chartLog.terminalReady) { resolve(); return; }
|
||||
if (++attempts >= maxAttempts) {
|
||||
console.warn('ops-view-logs: terminal ready timeout after 10s');
|
||||
resolve(); // resolve gracefully to avoid blocking
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 50);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
private updateFilter(type: string, value: string) {
|
||||
if (value === 'all') {
|
||||
value = undefined;
|
||||
const allEntries = this.getMappedLogEntries();
|
||||
if (this.lastPushedCount === 0 && allEntries.length > 0) {
|
||||
// Initial load: push all entries
|
||||
chartLog.updateLog(allEntries);
|
||||
this.lastPushedCount = allEntries.length;
|
||||
} else if (allEntries.length > this.lastPushedCount) {
|
||||
// Incremental: only push new entries
|
||||
const newEntries = allEntries.slice(this.lastPushedCount);
|
||||
chartLog.updateLog(newEntries);
|
||||
this.lastPushedCount = allEntries.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Update filters then fetch logs
|
||||
this.fetchLogs();
|
||||
private getMappedLogEntries() {
|
||||
return this.logState.recentLogs.map((log) => ({
|
||||
timestamp: new Date(log.timestamp).toISOString(),
|
||||
level: log.level as 'debug' | 'info' | 'warn' | 'error',
|
||||
message: log.message,
|
||||
source: log.category,
|
||||
}));
|
||||
}
|
||||
|
||||
private getActiveFilters() {
|
||||
return {
|
||||
level: this.logState.filters.level?.[0],
|
||||
category: this.logState.filters.category?.[0],
|
||||
limit: 100,
|
||||
};
|
||||
}
|
||||
|
||||
private toggleStreaming() {
|
||||
// TODO: Implement log streaming with VirtualStream
|
||||
console.log('Streaming toggle not yet implemented');
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,9 @@ export class OpsViewNetwork extends DeesElement {
|
||||
private lastChartUpdate = 0;
|
||||
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
|
||||
|
||||
private lastTrafficUpdateTime = 0;
|
||||
private trafficUpdateInterval = 1000; // Update every 1 second
|
||||
private requestCountHistory = new Map<number, number>(); // Track requests per time bucket
|
||||
private trafficUpdateTimer: any = null;
|
||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||
|
||||
// Removed byte tracking - now using real-time data from SmartProxy
|
||||
private historyLoaded = false; // Whether server-side throughput history has been loaded
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -107,8 +103,54 @@ export class OpsViewNetwork extends DeesElement {
|
||||
|
||||
this.trafficDataIn = [...emptyData];
|
||||
this.trafficDataOut = emptyData.map(point => ({ ...point }));
|
||||
}
|
||||
|
||||
this.lastTrafficUpdateTime = now;
|
||||
/**
|
||||
* Load server-side throughput history into the chart.
|
||||
* Called once when history data first arrives from the Rust engine.
|
||||
* This pre-populates the chart so users see historical data immediately
|
||||
* instead of starting from all zeros.
|
||||
*/
|
||||
private loadThroughputHistory() {
|
||||
const history = this.networkState.throughputHistory;
|
||||
if (!history || history.length === 0) return;
|
||||
|
||||
this.historyLoaded = true;
|
||||
|
||||
// Convert history points to chart data format (bytes/sec → Mbit/s)
|
||||
const historyIn = history.map(p => ({
|
||||
x: new Date(p.timestamp).toISOString(),
|
||||
y: Math.round((p.in * 8) / 1000000 * 10) / 10,
|
||||
}));
|
||||
const historyOut = history.map(p => ({
|
||||
x: new Date(p.timestamp).toISOString(),
|
||||
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
|
||||
}));
|
||||
|
||||
// Use history as the chart data, keeping the most recent 60 points (5 min window)
|
||||
const sliceStart = Math.max(0, historyIn.length - 60);
|
||||
this.trafficDataIn = historyIn.slice(sliceStart);
|
||||
this.trafficDataOut = historyOut.slice(sliceStart);
|
||||
|
||||
// If fewer than 60 points, pad the front with zeros
|
||||
if (this.trafficDataIn.length < 60) {
|
||||
const now = Date.now();
|
||||
const range = 5 * 60 * 1000;
|
||||
const bucketSize = range / 60;
|
||||
const padCount = 60 - this.trafficDataIn.length;
|
||||
const firstTimestamp = this.trafficDataIn.length > 0
|
||||
? new Date(this.trafficDataIn[0].x).getTime()
|
||||
: now;
|
||||
|
||||
const padIn = Array.from({ length: padCount }, (_, i) => ({
|
||||
x: new Date(firstTimestamp - ((padCount - i) * bucketSize)).toISOString(),
|
||||
y: 0,
|
||||
}));
|
||||
const padOut = padIn.map(p => ({ ...p }));
|
||||
|
||||
this.trafficDataIn = [...padIn, ...this.trafficDataIn];
|
||||
this.trafficDataOut = [...padOut, ...this.trafficDataOut];
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -240,7 +282,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'magnifyingGlass',
|
||||
iconName: 'fa:magnifyingGlass',
|
||||
type: ['inRow', 'doubleClick', 'contextmenu'],
|
||||
actionFunc: async (actionData) => {
|
||||
await this.showRequestDetails(actionData.item);
|
||||
@@ -289,7 +331,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Copy Request ID',
|
||||
iconName: 'copy',
|
||||
iconName: 'lucide:Copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(request.id);
|
||||
}
|
||||
@@ -352,21 +394,6 @@ export class OpsViewNetwork extends DeesElement {
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
private calculateRequestsPerSecond(): number {
|
||||
// Calculate from actual request data in the last minute
|
||||
const oneMinuteAgo = Date.now() - 60000;
|
||||
const recentRequests = this.networkRequests.filter(req => req.timestamp >= oneMinuteAgo);
|
||||
const reqPerSec = Math.round(recentRequests.length / 60);
|
||||
|
||||
// Track history for trend (keep last 20 values)
|
||||
this.requestsPerSecHistory.push(reqPerSec);
|
||||
if (this.requestsPerSecHistory.length > 20) {
|
||||
this.requestsPerSecHistory.shift();
|
||||
}
|
||||
|
||||
return reqPerSec;
|
||||
}
|
||||
|
||||
private calculateThroughput(): { in: number; out: number } {
|
||||
// Use real throughput data from network state
|
||||
return {
|
||||
@@ -376,16 +403,13 @@ export class OpsViewNetwork extends DeesElement {
|
||||
}
|
||||
|
||||
private renderNetworkStats(): TemplateResult {
|
||||
const reqPerSec = this.calculateRequestsPerSecond();
|
||||
// Use server-side requests/sec from SmartProxy's Rust engine
|
||||
const reqPerSec = this.networkState.requestsPerSecond || 0;
|
||||
const throughput = this.calculateThroughput();
|
||||
const activeConnections = this.statsState.serverStats?.activeConnections || 0;
|
||||
|
||||
// Throughput data is now available in the stats tiles
|
||||
|
||||
// Use request count history for the requests/sec trend
|
||||
// Build trend data from pre-computed history (mutated in updateNetworkData, not here)
|
||||
const trendData = [...this.requestsPerSecHistory];
|
||||
|
||||
// If we don't have enough data, pad with zeros
|
||||
while (trendData.length < 20) {
|
||||
trendData.unshift(0);
|
||||
}
|
||||
@@ -396,13 +420,13 @@ export class OpsViewNetwork extends DeesElement {
|
||||
title: 'Active Connections',
|
||||
value: activeConnections,
|
||||
type: 'number',
|
||||
icon: 'plug',
|
||||
icon: 'lucide:Plug',
|
||||
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
||||
description: `Total: ${this.statsState.serverStats?.totalConnections || 0}`,
|
||||
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
|
||||
actions: [
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'magnifyingGlass',
|
||||
iconName: 'fa:magnifyingGlass',
|
||||
action: async () => {
|
||||
},
|
||||
},
|
||||
@@ -413,10 +437,10 @@ export class OpsViewNetwork extends DeesElement {
|
||||
title: 'Requests/sec',
|
||||
value: reqPerSec,
|
||||
type: 'trend',
|
||||
icon: 'chartLine',
|
||||
icon: 'lucide:ChartLine',
|
||||
color: '#3b82f6',
|
||||
trendData: trendData,
|
||||
description: `Average over last minute`,
|
||||
description: `Total: ${this.formatNumber(this.networkState.requestsTotal || 0)} requests`,
|
||||
},
|
||||
{
|
||||
id: 'throughputIn',
|
||||
@@ -424,8 +448,9 @@ export class OpsViewNetwork extends DeesElement {
|
||||
value: this.formatBitsPerSecond(throughput.in),
|
||||
unit: '',
|
||||
type: 'number',
|
||||
icon: 'download',
|
||||
icon: 'lucide:Download',
|
||||
color: '#22c55e',
|
||||
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.in || 0)}`,
|
||||
},
|
||||
{
|
||||
id: 'throughputOut',
|
||||
@@ -433,8 +458,9 @@ export class OpsViewNetwork extends DeesElement {
|
||||
value: this.formatBitsPerSecond(throughput.out),
|
||||
unit: '',
|
||||
type: 'number',
|
||||
icon: 'upload',
|
||||
icon: 'lucide:Upload',
|
||||
color: '#8b5cf6',
|
||||
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.out || 0)}`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -445,7 +471,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Export Data',
|
||||
iconName: 'fileExport',
|
||||
iconName: 'lucide:FileOutput',
|
||||
action: async () => {
|
||||
console.log('Export feature coming soon');
|
||||
},
|
||||
@@ -461,19 +487,32 @@ export class OpsViewNetwork extends DeesElement {
|
||||
return html``;
|
||||
}
|
||||
|
||||
// Build per-IP bandwidth lookup
|
||||
const bandwidthByIP = new Map<string, { in: number; out: number }>();
|
||||
if (this.networkState.throughputByIP) {
|
||||
for (const entry of this.networkState.throughputByIP) {
|
||||
bandwidthByIP.set(entry.ip, { in: entry.in, out: entry.out });
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total connections across all top IPs
|
||||
const totalConnections = this.networkState.topIPs.reduce((sum, ipData) => sum + ipData.count, 0);
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${this.networkState.topIPs}
|
||||
.displayFunction=${(ipData: { ip: string; count: number }) => ({
|
||||
.displayFunction=${(ipData: { ip: string; count: number }) => {
|
||||
const bw = bandwidthByIP.get(ipData.ip);
|
||||
return {
|
||||
'IP Address': ipData.ip,
|
||||
'Connections': ipData.count,
|
||||
'Percentage': totalConnections > 0 ? ((ipData.count / totalConnections) * 100).toFixed(1) + '%' : '0%',
|
||||
})}
|
||||
'Bandwidth In': bw ? this.formatBitsPerSecond(bw.in) : '0 bit/s',
|
||||
'Bandwidth Out': bw ? this.formatBitsPerSecond(bw.out) : '0 bit/s',
|
||||
'Share': totalConnections > 0 ? ((ipData.count / totalConnections) * 100).toFixed(1) + '%' : '0%',
|
||||
};
|
||||
}}
|
||||
heading1="Top Connected IPs"
|
||||
heading2="IPs with most active connections"
|
||||
heading2="IPs with most active connections and bandwidth"
|
||||
.pagination=${false}
|
||||
dataName="ip"
|
||||
></dees-table>
|
||||
@@ -481,6 +520,13 @@ export class OpsViewNetwork extends DeesElement {
|
||||
}
|
||||
|
||||
private async updateNetworkData() {
|
||||
// Track requests/sec history for the trend sparkline (moved out of render)
|
||||
const reqPerSec = this.networkState.requestsPerSecond || 0;
|
||||
this.requestsPerSecHistory.push(reqPerSec);
|
||||
if (this.requestsPerSecHistory.length > 20) {
|
||||
this.requestsPerSecHistory.shift();
|
||||
}
|
||||
|
||||
// Only update if connections changed significantly
|
||||
const newConnectionCount = this.networkState.connections.length;
|
||||
const oldConnectionCount = this.networkRequests.length;
|
||||
@@ -513,13 +559,10 @@ export class OpsViewNetwork extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate traffic data based on request history
|
||||
this.updateTrafficData();
|
||||
// Load server-side throughput history into chart (once)
|
||||
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
|
||||
this.loadThroughputHistory();
|
||||
}
|
||||
|
||||
private updateTrafficData() {
|
||||
// This method is called when network data updates
|
||||
// The actual chart updates are handled by the timer calling addTrafficDataPoint()
|
||||
}
|
||||
|
||||
private startTrafficUpdateTimer() {
|
||||
@@ -557,16 +600,13 @@ export class OpsViewNetwork extends DeesElement {
|
||||
y: Math.round(throughputOutMbps * 10) / 10
|
||||
};
|
||||
|
||||
// Efficient array updates - modify in place when possible
|
||||
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
|
||||
if (this.trafficDataIn.length >= 60) {
|
||||
// Remove oldest and add newest
|
||||
this.trafficDataIn = [...this.trafficDataIn.slice(1), newDataPointIn];
|
||||
this.trafficDataOut = [...this.trafficDataOut.slice(1), newDataPointOut];
|
||||
} else {
|
||||
// Still filling up the initial data
|
||||
this.trafficDataIn.shift();
|
||||
this.trafficDataOut.shift();
|
||||
}
|
||||
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
||||
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
||||
}
|
||||
|
||||
this.lastChartUpdate = now;
|
||||
}
|
||||
|
||||
@@ -26,14 +26,36 @@ export class OpsViewOverview extends DeesElement {
|
||||
error: null,
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor logState: appstate.ILogState = {
|
||||
recentLogs: [],
|
||||
isStreaming: false,
|
||||
filters: {},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.statsStatePart
|
||||
const statsSub = appstate.statsStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((statsState) => {
|
||||
this.statsState = statsState;
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
this.rxSubscriptions.push(statsSub);
|
||||
|
||||
const logSub = appstate.logStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((logState) => {
|
||||
this.logState = logState;
|
||||
});
|
||||
this.rxSubscriptions.push(logSub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Ensure logs are fetched for the overview charts
|
||||
if (this.logState.recentLogs.length === 0) {
|
||||
appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -96,10 +118,24 @@ export class OpsViewOverview extends DeesElement {
|
||||
${this.renderDnsStats()}
|
||||
|
||||
<div class="chartGrid">
|
||||
<dees-chart-area .label=${'Email Traffic (24h)'} .data=${[]}></dees-chart-area>
|
||||
<dees-chart-area .label=${'DNS Queries (24h)'} .data=${[]}></dees-chart-area>
|
||||
<dees-chart-log .label=${'Recent Events'} .data=${[]}></dees-chart-log>
|
||||
<dees-chart-log .label=${'Security Alerts'} .data=${[]}></dees-chart-log>
|
||||
<dees-chart-area
|
||||
.label=${'Email Traffic (24h)'}
|
||||
.series=${this.getEmailTrafficSeries()}
|
||||
.yAxisFormatter=${(val: number) => `${val}`}
|
||||
></dees-chart-area>
|
||||
<dees-chart-area
|
||||
.label=${'DNS Queries (24h)'}
|
||||
.series=${this.getDnsQuerySeries()}
|
||||
.yAxisFormatter=${(val: number) => `${val}`}
|
||||
></dees-chart-area>
|
||||
<dees-chart-log
|
||||
.label=${'Recent Events'}
|
||||
.logEntries=${this.getRecentEventEntries()}
|
||||
></dees-chart-log>
|
||||
<dees-chart-log
|
||||
.label=${'DNS Queries'}
|
||||
.logEntries=${this.getDnsQueryEntries()}
|
||||
></dees-chart-log>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
@@ -135,6 +171,20 @@ export class OpsViewOverview extends DeesElement {
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
private formatBitsPerSecond(bytesPerSecond: number): string {
|
||||
const bitsPerSecond = bytesPerSecond * 8;
|
||||
const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
|
||||
let size = bitsPerSecond;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||
size /= 1000;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
private renderServerStats(): TemplateResult {
|
||||
if (!this.statsState.serverStats) return html``;
|
||||
|
||||
@@ -149,7 +199,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Server Status',
|
||||
value: this.statsState.serverStats.uptime ? 'Online' : 'Offline',
|
||||
type: 'text',
|
||||
icon: 'server',
|
||||
icon: 'lucide:Server',
|
||||
color: this.statsState.serverStats.uptime ? '#22c55e' : '#ef4444',
|
||||
description: `Uptime: ${this.formatUptime(this.statsState.serverStats.uptime)}`,
|
||||
},
|
||||
@@ -158,16 +208,34 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Active Connections',
|
||||
value: this.statsState.serverStats.activeConnections,
|
||||
type: 'number',
|
||||
icon: 'networkWired',
|
||||
icon: 'lucide:Network',
|
||||
color: '#3b82f6',
|
||||
description: `Total: ${this.statsState.serverStats.totalConnections}`,
|
||||
},
|
||||
{
|
||||
id: 'throughputIn',
|
||||
title: 'Throughput In',
|
||||
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesInPerSecond || 0),
|
||||
type: 'text',
|
||||
icon: 'lucide:Download',
|
||||
color: '#22c55e',
|
||||
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesIn || 0)}`,
|
||||
},
|
||||
{
|
||||
id: 'throughputOut',
|
||||
title: 'Throughput Out',
|
||||
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesOutPerSecond || 0),
|
||||
type: 'text',
|
||||
icon: 'lucide:Upload',
|
||||
color: '#8b5cf6',
|
||||
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesOut || 0)}`,
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
title: 'CPU Usage',
|
||||
value: cpuUsage,
|
||||
type: 'gauge',
|
||||
icon: 'microchip',
|
||||
icon: 'lucide:Cpu',
|
||||
gaugeOptions: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
@@ -183,7 +251,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Memory Usage',
|
||||
value: memoryUsage,
|
||||
type: 'percentage',
|
||||
icon: 'memory',
|
||||
icon: 'lucide:MemoryStick',
|
||||
color: memoryUsage > 80 ? '#ef4444' : memoryUsage > 60 ? '#f59e0b' : '#22c55e',
|
||||
description: this.statsState.serverStats.memoryUsage.actualUsageBytes !== undefined && this.statsState.serverStats.memoryUsage.maxMemoryMB !== undefined
|
||||
? `${this.formatBytes(this.statsState.serverStats.memoryUsage.actualUsageBytes)} / ${this.formatBytes(this.statsState.serverStats.memoryUsage.maxMemoryMB * 1024 * 1024)}`
|
||||
@@ -197,7 +265,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'arrowsRotate',
|
||||
iconName: 'lucide:RefreshCw',
|
||||
action: async () => {
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
},
|
||||
@@ -219,7 +287,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Emails Sent',
|
||||
value: this.statsState.emailStats.sent,
|
||||
type: 'number',
|
||||
icon: 'paperPlane',
|
||||
icon: 'lucide:Send',
|
||||
color: '#22c55e',
|
||||
description: `Delivery rate: ${(deliveryRate * 100).toFixed(1)}%`,
|
||||
},
|
||||
@@ -228,7 +296,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Emails Received',
|
||||
value: this.statsState.emailStats.received,
|
||||
type: 'number',
|
||||
icon: 'envelope',
|
||||
icon: 'lucide:Mail',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
@@ -236,7 +304,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Queued',
|
||||
value: this.statsState.emailStats.queued,
|
||||
type: 'number',
|
||||
icon: 'clock',
|
||||
icon: 'lucide:Clock',
|
||||
color: '#f59e0b',
|
||||
description: 'Pending delivery',
|
||||
},
|
||||
@@ -245,7 +313,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Failed',
|
||||
value: this.statsState.emailStats.failed,
|
||||
type: 'number',
|
||||
icon: 'triangleExclamation',
|
||||
icon: 'lucide:TriangleAlert',
|
||||
color: '#ef4444',
|
||||
description: `Bounce rate: ${(bounceRate * 100).toFixed(1)}%`,
|
||||
},
|
||||
@@ -268,7 +336,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'DNS Queries',
|
||||
value: this.statsState.dnsStats.totalQueries,
|
||||
type: 'number',
|
||||
icon: 'globe',
|
||||
icon: 'lucide:Globe',
|
||||
color: '#3b82f6',
|
||||
description: 'Total queries handled',
|
||||
},
|
||||
@@ -277,7 +345,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Cache Hit Rate',
|
||||
value: cacheHitRate,
|
||||
type: 'percentage',
|
||||
icon: 'database',
|
||||
icon: 'lucide:Database',
|
||||
color: cacheHitRate > 80 ? '#22c55e' : cacheHitRate > 60 ? '#f59e0b' : '#ef4444',
|
||||
description: `${this.statsState.dnsStats.cacheHits} hits / ${this.statsState.dnsStats.cacheMisses} misses`,
|
||||
},
|
||||
@@ -286,7 +354,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
title: 'Active Domains',
|
||||
value: this.statsState.dnsStats.activeDomains,
|
||||
type: 'number',
|
||||
icon: 'sitemap',
|
||||
icon: 'lucide:Network',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
@@ -295,7 +363,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
value: this.statsState.dnsStats.averageResponseTime.toFixed(1),
|
||||
unit: 'ms',
|
||||
type: 'number',
|
||||
icon: 'clockRotateLeft',
|
||||
icon: 'lucide:History',
|
||||
color: this.statsState.dnsStats.averageResponseTime < 50 ? '#22c55e' : '#f59e0b',
|
||||
},
|
||||
];
|
||||
@@ -305,4 +373,52 @@ export class OpsViewOverview extends DeesElement {
|
||||
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Chart data helpers ---
|
||||
|
||||
private getRecentEventEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||
return this.logState.recentLogs.map((log) => ({
|
||||
timestamp: new Date(log.timestamp).toISOString(),
|
||||
level: log.level as 'debug' | 'info' | 'warn' | 'error',
|
||||
message: log.message,
|
||||
source: log.category,
|
||||
}));
|
||||
}
|
||||
|
||||
private getSecurityAlertEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||
const events: any[] = this.statsState.securityMetrics?.recentEvents || [];
|
||||
return events.map((evt: any) => ({
|
||||
timestamp: new Date(evt.timestamp).toISOString(),
|
||||
level: evt.level === 'critical' || evt.level === 'error' ? 'error' as const : evt.level === 'warn' ? 'warn' as const : 'info' as const,
|
||||
message: evt.message,
|
||||
source: evt.type,
|
||||
}));
|
||||
}
|
||||
|
||||
private getDnsQueryEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||
const queries: any[] = (this.statsState.dnsStats as any)?.recentQueries || [];
|
||||
return queries.map((q: any) => ({
|
||||
timestamp: new Date(q.timestamp).toISOString(),
|
||||
level: q.answered ? 'info' as const : 'warn' as const,
|
||||
message: `${q.type} ${q.domain} (${q.responseTimeMs}ms)`,
|
||||
source: 'dns',
|
||||
}));
|
||||
}
|
||||
|
||||
private getEmailTrafficSeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> {
|
||||
const ts = this.statsState.emailStats?.timeSeries;
|
||||
if (!ts) return [];
|
||||
return [
|
||||
{ name: 'Sent', color: '#22c55e', data: (ts.sent || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||
{ name: 'Received', color: '#3b82f6', data: (ts.received || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||
];
|
||||
}
|
||||
|
||||
private getDnsQuerySeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> {
|
||||
const ts = this.statsState.dnsStats?.timeSeries;
|
||||
if (!ts) return [];
|
||||
return [
|
||||
{ name: 'Queries', color: '#8b5cf6', data: (ts.queries || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||
];
|
||||
}
|
||||
}
|
||||
467
ts_web/elements/ops-view-remoteingress.ts
Normal file
467
ts_web/elements/ops-view-remoteingress.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
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-remoteingress': OpsViewRemoteIngress;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-remoteingress')
|
||||
export class OpsViewRemoteIngress extends DeesElement {
|
||||
@state()
|
||||
accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => {
|
||||
this.riState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.remoteIngressStatePart.dispatchAction(appstate.fetchRemoteIngressAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.remoteIngressContainer {
|
||||
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.connected {
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.statusBadge.disconnected {
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.statusBadge.disabled {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
.secretDialog {
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
|
||||
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.secretDialog code {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#1f2937', '#111827')};
|
||||
color: #10b981;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
margin: 8px 0;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.secretDialog .warning {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.portsDisplay {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portBadge {
|
||||
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')};
|
||||
}
|
||||
|
||||
.portBadge.manual {
|
||||
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||
}
|
||||
|
||||
.portBadge.derived {
|
||||
background: ${cssManager.bdTheme('#ecfdf5', '#022c22')};
|
||||
color: ${cssManager.bdTheme('#047857', '#34d399')};
|
||||
border: 1px dashed ${cssManager.bdTheme('#6ee7b7', '#065f46')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
const totalEdges = this.riState.edges.length;
|
||||
const connectedEdges = this.riState.statuses.filter(s => s.connected).length;
|
||||
const disconnectedEdges = totalEdges - connectedEdges;
|
||||
const activeTunnels = this.riState.statuses.reduce((sum, s) => sum + s.activeTunnels, 0);
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalEdges',
|
||||
title: 'Total Edges',
|
||||
type: 'number',
|
||||
value: totalEdges,
|
||||
icon: 'lucide:server',
|
||||
description: 'Registered edge nodes',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'connectedEdges',
|
||||
title: 'Connected',
|
||||
type: 'number',
|
||||
value: connectedEdges,
|
||||
icon: 'lucide:link',
|
||||
description: 'Currently connected edges',
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'disconnectedEdges',
|
||||
title: 'Disconnected',
|
||||
type: 'number',
|
||||
value: disconnectedEdges,
|
||||
icon: 'lucide:unlink',
|
||||
description: 'Offline edge nodes',
|
||||
color: disconnectedEdges > 0 ? '#ef4444' : '#6b7280',
|
||||
},
|
||||
{
|
||||
id: 'activeTunnels',
|
||||
title: 'Active Tunnels',
|
||||
type: 'number',
|
||||
value: activeTunnels,
|
||||
icon: 'lucide:cable',
|
||||
description: 'Active client connections',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Remote Ingress</ops-sectionheading>
|
||||
|
||||
${this.riState.newEdgeId ? html`
|
||||
<div class="secretDialog">
|
||||
<strong>Edge created successfully!</strong>
|
||||
<div class="warning">Copy the connection token now. Use it with edge.start({ token: '...' }).</div>
|
||||
<dees-button
|
||||
@click=${async () => {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
try {
|
||||
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);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = response.token;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
DeesToast.show({ message: 'Connection token copied!', type: 'success', duration: 3000 });
|
||||
} 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 });
|
||||
}
|
||||
}}
|
||||
>Copy Connection Token</dees-button>
|
||||
<dees-button
|
||||
@click=${() => appstate.remoteIngressStatePart.dispatchAction(appstate.clearNewEdgeIdAction, null)}
|
||||
>Dismiss</dees-button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="remoteIngressContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
|
||||
<dees-table
|
||||
.heading1=${'Edge Nodes'}
|
||||
.heading2=${'Manage remote ingress edge registrations'}
|
||||
.data=${this.riState.edges}
|
||||
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
|
||||
name: edge.name,
|
||||
status: this.getEdgeStatusHtml(edge),
|
||||
publicIp: this.getEdgePublicIp(edge.id),
|
||||
ports: this.getPortsHtml(edge),
|
||||
tunnels: this.getEdgeTunnelCount(edge.id),
|
||||
lastHeartbeat: this.getLastHeartbeat(edge.id),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Create Edge Node',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const modal = await DeesModal.createAndShow({
|
||||
heading: 'Create Edge Node',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Additional Manual Ports (comma-separated, optional)'}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:plus',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
const name = formData.name;
|
||||
if (!name) return;
|
||||
const portsStr = formData.listenPorts?.trim();
|
||||
const listenPorts = portsStr
|
||||
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
|
||||
: undefined;
|
||||
const autoDerivePorts = formData.autoDerivePorts !== false;
|
||||
const tags = formData.tags
|
||||
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.createRemoteIngressAction,
|
||||
{ name, listenPorts, autoDerivePorts, tags },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Enable',
|
||||
iconName: 'lucide:play',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||
await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.toggleRemoteIngressAction,
|
||||
{ id: edge.id, enabled: true },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Disable',
|
||||
iconName: 'lucide:pause',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||
await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.toggleRemoteIngressAction,
|
||||
{ id: edge.id, enabled: false },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Edit Edge: ${edge.name}`,
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports (comma-separated)'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated)'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
const portsStr = formData.listenPorts?.trim();
|
||||
const listenPorts = portsStr
|
||||
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
|
||||
: [];
|
||||
const autoDerivePorts = formData.autoDerivePorts !== false;
|
||||
const tags = formData.tags
|
||||
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.updateRemoteIngressAction,
|
||||
{
|
||||
id: edge.id,
|
||||
name: formData.name || edge.name,
|
||||
listenPorts,
|
||||
autoDerivePorts,
|
||||
tags,
|
||||
},
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Regenerate Secret',
|
||||
iconName: 'lucide:key',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||
await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.regenerateRemoteIngressSecretAction,
|
||||
edge.id,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Token',
|
||||
iconName: 'lucide:ClipboardCopy',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
try {
|
||||
const response = await appstate.fetchConnectionToken(edge.id);
|
||||
if (response.success && response.token) {
|
||||
// Use clipboard API with fallback for non-HTTPS contexts
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(response.token);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = response.token;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
DeesToast.show({ message: `Connection token copied for ${edge.name}`, type: 'success', duration: 3000 });
|
||||
} 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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||
await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.deleteRemoteIngressAction,
|
||||
edge.id,
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getEdgeStatus(edgeId: string): interfaces.data.IRemoteIngressStatus | undefined {
|
||||
return this.riState.statuses.find(s => s.edgeId === edgeId);
|
||||
}
|
||||
|
||||
private getEdgeStatusHtml(edge: interfaces.data.IRemoteIngress): TemplateResult {
|
||||
if (!edge.enabled) {
|
||||
return html`<span class="statusBadge disabled">Disabled</span>`;
|
||||
}
|
||||
const status = this.getEdgeStatus(edge.id);
|
||||
if (status?.connected) {
|
||||
return html`<span class="statusBadge connected">Connected</span>`;
|
||||
}
|
||||
return html`<span class="statusBadge disconnected">Disconnected</span>`;
|
||||
}
|
||||
|
||||
private getEdgePublicIp(edgeId: string): string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
return status?.publicIp || '-';
|
||||
}
|
||||
|
||||
private getPortsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult {
|
||||
const manualPorts = edge.manualPorts || [];
|
||||
const derivedPorts = edge.derivedPorts || [];
|
||||
if (manualPorts.length === 0 && derivedPorts.length === 0) {
|
||||
return html`<span style="color: var(--text-muted, #6b7280); font-size: 12px;">none</span>`;
|
||||
}
|
||||
return html`<div class="portsDisplay">${manualPorts.map(p => html`<span class="portBadge manual">${p}</span>`)}${derivedPorts.map(p => html`<span class="portBadge derived">${p}</span>`)}${derivedPorts.length > 0 ? html`<span style="font-size: 11px; color: var(--text-muted, #6b7280); align-self: center;">(auto)</span>` : ''}</div>`;
|
||||
}
|
||||
|
||||
private getEdgeTunnelCount(edgeId: string): number {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
return status?.activeTunnels || 0;
|
||||
}
|
||||
|
||||
private getLastHeartbeat(edgeId: string): string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.lastHeartbeat) return '-';
|
||||
const ago = Date.now() - status.lastHeartbeat;
|
||||
if (ago < 60000) return `${Math.floor(ago / 1000)}s ago`;
|
||||
if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
|
||||
return `${Math.floor(ago / 3600000)}h ago`;
|
||||
}
|
||||
}
|
||||
389
ts_web/elements/ops-view-routes.ts
Normal file
389
ts_web/elements/ops-view-routes.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
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';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('ops-view-routes')
|
||||
export class OpsViewRoutes extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.routeManagementStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((routeState) => {
|
||||
this.routeState = routeState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
// Re-fetch routes when user logs in (fixes race condition where
|
||||
// the view is created before authentication completes)
|
||||
const loginSub = appstate.loginStatePart
|
||||
.select((s) => s.isLoggedIn)
|
||||
.subscribe((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.routesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.warnings-bar {
|
||||
background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')};
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')};
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.warning-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#b45309', '#fa0')};
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#666')};
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { mergedRoutes, warnings } = this.routeState;
|
||||
|
||||
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
|
||||
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
|
||||
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalRoutes',
|
||||
title: 'Total Routes',
|
||||
type: 'number',
|
||||
value: mergedRoutes.length,
|
||||
icon: 'lucide:route',
|
||||
description: 'All configured routes',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'hardcoded',
|
||||
title: 'Hardcoded',
|
||||
type: 'number',
|
||||
value: hardcodedCount,
|
||||
icon: 'lucide:lock',
|
||||
description: 'Routes from constructor config',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'programmatic',
|
||||
title: 'Programmatic',
|
||||
type: 'number',
|
||||
value: programmaticCount,
|
||||
icon: 'lucide:code',
|
||||
description: 'Routes added via API',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
{
|
||||
id: 'disabled',
|
||||
title: 'Disabled',
|
||||
type: 'number',
|
||||
value: disabledCount,
|
||||
icon: 'lucide:pauseCircle',
|
||||
description: 'Currently disabled routes',
|
||||
color: disabledCount > 0 ? '#ef4444' : '#6b7280',
|
||||
},
|
||||
];
|
||||
|
||||
// Map merged routes to sz-route-list-view format
|
||||
const szRoutes = mergedRoutes.map((mr) => {
|
||||
const tags = [...(mr.route.tags || [])];
|
||||
tags.push(mr.source);
|
||||
if (!mr.enabled) tags.push('disabled');
|
||||
if (mr.overridden) tags.push('overridden');
|
||||
|
||||
return {
|
||||
...mr.route,
|
||||
enabled: mr.enabled,
|
||||
tags,
|
||||
id: mr.storedRouteId || mr.route.name || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Route Management</ops-sectionheading>
|
||||
|
||||
<div class="routesContainer">
|
||||
<dees-statsgrid
|
||||
.tiles=${statsTiles}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Add Route',
|
||||
iconName: 'lucide:plus',
|
||||
action: () => this.showCreateRouteDialog(),
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:refreshCw',
|
||||
action: () => this.refreshData(),
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
|
||||
${warnings.length > 0
|
||||
? html`
|
||||
<div class="warnings-bar">
|
||||
${warnings.map(
|
||||
(w) => html`
|
||||
<div class="warning-item">
|
||||
<span class="warning-icon">⚠</span>
|
||||
<span>${w.message}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
${szRoutes.length > 0
|
||||
? html`
|
||||
<sz-route-list-view
|
||||
.routes=${szRoutes}
|
||||
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
||||
></sz-route-list-view>
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<p>No routes configured</p>
|
||||
<p>Add a programmatic route or check your constructor configuration.</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async handleRouteClick(e: CustomEvent) {
|
||||
const clickedRoute = e.detail;
|
||||
if (!clickedRoute) return;
|
||||
|
||||
// Find the corresponding merged route
|
||||
const merged = this.routeState.mergedRoutes.find(
|
||||
(mr) => mr.route.name === clickedRoute.name,
|
||||
);
|
||||
if (!merged) return;
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
if (merged.source === 'hardcoded') {
|
||||
const menuOptions = merged.enabled
|
||||
? [
|
||||
{
|
||||
name: 'Disable Route',
|
||||
iconName: 'lucide:pause',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: false },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Enable Route',
|
||||
iconName: 'lucide:play',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: true },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Remove Override',
|
||||
iconName: 'lucide:undo',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.removeRouteOverrideAction,
|
||||
merged.route.name!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
|
||||
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions,
|
||||
});
|
||||
} else {
|
||||
// Programmatic route
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: merged.enabled ? 'Disable' : 'Enable',
|
||||
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.toggleRouteAction,
|
||||
{ id: merged.storedRouteId!, enabled: !merged.enabled },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.deleteRouteAction,
|
||||
merged.storedRouteId!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async showCreateRouteDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Add Programmatic Route',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, optional)'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .value=${'localhost'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .required=${true}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:plus',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
if (!formData.name || !formData.ports) return;
|
||||
|
||||
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
|
||||
const domains = formData.domains
|
||||
? formData.domains.split(',').map((d: string) => d.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const route: any = {
|
||||
name: formData.name,
|
||||
match: {
|
||||
ports,
|
||||
...(domains && domains.length > 0 ? { domains } : {}),
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{
|
||||
host: formData.targetHost || 'localhost',
|
||||
port: parseInt(formData.targetPort, 10),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.createRouteAction,
|
||||
{ route },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private refreshData() {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
}
|
||||
@@ -250,13 +250,20 @@ export class OpsViewSecurity extends DeesElement {
|
||||
const threatLevel = this.calculateThreatLevel(metrics);
|
||||
const threatScore = this.getThreatScore(metrics);
|
||||
|
||||
// Derive active sessions from recent successful auth events (last hour)
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const recentAuthSuccesses = allEvents.filter(
|
||||
(evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo
|
||||
).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'threatLevel',
|
||||
title: 'Threat Level',
|
||||
value: threatScore,
|
||||
type: 'gauge',
|
||||
icon: 'shield',
|
||||
icon: 'lucide:Shield',
|
||||
gaugeOptions: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
@@ -271,27 +278,27 @@ export class OpsViewSecurity extends DeesElement {
|
||||
{
|
||||
id: 'blockedThreats',
|
||||
title: 'Blocked Threats',
|
||||
value: metrics.blockedIPs.length + metrics.spamDetected,
|
||||
value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'userShield',
|
||||
icon: 'lucide:ShieldCheck',
|
||||
color: '#ef4444',
|
||||
description: 'Total threats blocked today',
|
||||
},
|
||||
{
|
||||
id: 'activeSessions',
|
||||
title: 'Active Sessions',
|
||||
value: 0,
|
||||
value: recentAuthSuccesses,
|
||||
type: 'number',
|
||||
icon: 'users',
|
||||
icon: 'lucide:Users',
|
||||
color: '#22c55e',
|
||||
description: 'Current authenticated sessions',
|
||||
description: 'Authenticated in last hour',
|
||||
},
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Auth Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lockOpen',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed login attempts today',
|
||||
},
|
||||
@@ -349,27 +356,41 @@ export class OpsViewSecurity extends DeesElement {
|
||||
}
|
||||
|
||||
private renderAuthentication(metrics: any) {
|
||||
// Derive auth events from recentEvents
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication');
|
||||
const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Authentication Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lockOpen',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed authentication attempts today',
|
||||
},
|
||||
{
|
||||
id: 'successfulLogins',
|
||||
title: 'Successful Logins',
|
||||
value: 0,
|
||||
value: successfulLogins,
|
||||
type: 'number',
|
||||
icon: 'lock',
|
||||
icon: 'lucide:Lock',
|
||||
color: '#22c55e',
|
||||
description: 'Successful logins today',
|
||||
},
|
||||
];
|
||||
|
||||
// Map auth events to login history table data
|
||||
const loginHistory = authEvents.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
username: evt.details?.username || 'unknown',
|
||||
ipAddress: evt.ipAddress || 'unknown',
|
||||
success: evt.success ?? false,
|
||||
reason: evt.success ? '' : evt.message || 'Authentication failed',
|
||||
}));
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
@@ -380,7 +401,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
<dees-table
|
||||
.heading1=${'Login History'}
|
||||
.heading2=${'Recent authentication attempts'}
|
||||
.data=${[]}
|
||||
.data=${loginHistory}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleString(),
|
||||
'Username': item.username,
|
||||
@@ -399,7 +420,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
title: 'Malware Detection',
|
||||
value: metrics.malwareDetected,
|
||||
type: 'number',
|
||||
icon: 'virusSlash',
|
||||
icon: 'lucide:BugOff',
|
||||
color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Malware detected',
|
||||
},
|
||||
@@ -408,7 +429,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
title: 'Phishing Detection',
|
||||
value: metrics.phishingDetected,
|
||||
type: 'number',
|
||||
icon: 'fishFins',
|
||||
icon: 'lucide:Fish',
|
||||
color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Phishing attempts detected',
|
||||
},
|
||||
@@ -417,7 +438,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
title: 'Suspicious Activities',
|
||||
value: metrics.suspiciousActivities,
|
||||
type: 'number',
|
||||
icon: 'triangleExclamation',
|
||||
icon: 'lucide:TriangleAlert',
|
||||
color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Suspicious activities detected',
|
||||
},
|
||||
@@ -426,7 +447,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
title: 'Spam Detection',
|
||||
value: metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'ban',
|
||||
icon: 'lucide:Ban',
|
||||
color: '#f59e0b',
|
||||
description: 'Spam emails blocked',
|
||||
},
|
||||
@@ -483,48 +504,38 @@ export class OpsViewSecurity extends DeesElement {
|
||||
private getThreatScore(metrics: any): number {
|
||||
// Simple scoring algorithm
|
||||
let score = 100;
|
||||
score -= metrics.blockedIPs.length * 2;
|
||||
score -= metrics.authenticationFailures * 1;
|
||||
score -= metrics.spamDetected * 0.5;
|
||||
score -= metrics.malwareDetected * 3;
|
||||
score -= metrics.phishingDetected * 3;
|
||||
score -= metrics.suspiciousActivities * 2;
|
||||
const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0);
|
||||
score -= blockedCount * 2;
|
||||
score -= (metrics.authenticationFailures || 0) * 1;
|
||||
score -= (metrics.spamDetected || 0) * 0.5;
|
||||
score -= (metrics.malwareDetected || 0) * 3;
|
||||
score -= (metrics.phishingDetected || 0) * 3;
|
||||
score -= (metrics.suspiciousActivities || 0) * 2;
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
private getSecurityEvents(metrics: any): any[] {
|
||||
// Mock data - in real implementation, this would come from the server
|
||||
return [
|
||||
{
|
||||
timestamp: Date.now() - 1000 * 60 * 5,
|
||||
event: 'Multiple failed login attempts',
|
||||
severity: 'warning',
|
||||
details: 'IP: 192.168.1.100',
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 1000 * 60 * 15,
|
||||
event: 'SPF check failed',
|
||||
severity: 'medium',
|
||||
details: 'Domain: example.com',
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 1000 * 60 * 30,
|
||||
event: 'IP blocked due to spam',
|
||||
severity: 'high',
|
||||
details: 'IP: 10.0.0.1',
|
||||
},
|
||||
];
|
||||
const events: any[] = metrics.recentEvents || [];
|
||||
return events.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
event: evt.message,
|
||||
severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info',
|
||||
details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type,
|
||||
}));
|
||||
}
|
||||
|
||||
private async clearBlockedIPs() {
|
||||
console.log('Clear blocked IPs');
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert('Clearing blocked IPs is not yet supported from the UI.');
|
||||
}
|
||||
|
||||
private async unblockIP(ip: string) {
|
||||
console.log('Unblock IP:', ip);
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
|
||||
}
|
||||
|
||||
private async saveEmailSecuritySettings() {
|
||||
console.log('Save email security settings');
|
||||
// Config is read-only from the UI for now
|
||||
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,17 @@
|
||||
import * as deesElement from '@design.estate/dees-element';
|
||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
// @serve.zone scope
|
||||
import * as szCatalog from '@serve.zone/catalog';
|
||||
|
||||
// TypedSocket for real-time push communication
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export {
|
||||
deesElement,
|
||||
deesCatalog
|
||||
deesCatalog,
|
||||
szCatalog,
|
||||
typedsocket,
|
||||
}
|
||||
|
||||
// domtools gives us TypedRequest and other utilities
|
||||
|
||||
@@ -34,11 +34,32 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **Security** — Security incidents from email processing
|
||||
- Bounce record management and suppression list controls
|
||||
|
||||
### 🔐 Certificate Management
|
||||
- Domain-centric certificate overview with status indicators
|
||||
- Certificate source tracking (ACME, provision function, static)
|
||||
- Expiry date monitoring and alerts
|
||||
- Per-domain backoff status for failed provisions
|
||||
- One-click reprovisioning per domain
|
||||
- Certificate import, export, and deletion
|
||||
|
||||
### 🌍 Remote Ingress Management
|
||||
- Edge node registration with name, ports, and tags
|
||||
- Real-time connection status (connected/disconnected/disabled)
|
||||
- Public IP and active tunnel count per edge
|
||||
- Auto-derived port display with manual/derived breakdown
|
||||
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
|
||||
- Enable/disable, edit, secret regeneration, and delete actions
|
||||
|
||||
### 📜 Log Viewer
|
||||
- Real-time log streaming
|
||||
- Filter by log level (error, warning, info, debug)
|
||||
- Search and time-range selection
|
||||
|
||||
### 🛣️ Route & API Token Management
|
||||
- Programmatic route CRUD with enable/disable and override controls
|
||||
- API token creation, revocation, and scope management
|
||||
- Routes tab and API Tokens tab in unified view
|
||||
|
||||
### ⚙️ Configuration
|
||||
- Read-only display of current system configuration
|
||||
- Status badges for boolean values (enabled/disabled)
|
||||
@@ -77,7 +98,10 @@ ts_web/
|
||||
├── ops-view-overview.ts # Overview statistics
|
||||
├── ops-view-network.ts # Network monitoring
|
||||
├── ops-view-emails.ts # Email queue management
|
||||
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
||||
├── ops-view-remoteingress.ts # Remote ingress edge management
|
||||
├── ops-view-logs.ts # Log viewer
|
||||
├── ops-view-routes.ts # Route & API token management
|
||||
├── ops-view-config.ts # Configuration display
|
||||
├── ops-view-security.ts # Security dashboard
|
||||
└── shared/
|
||||
@@ -98,6 +122,8 @@ The app uses `@push.rocks/smartstate` with multiple state parts:
|
||||
| `logStatePart` | Soft | Recent logs, streaming status, filters |
|
||||
| `networkStatePart` | Soft | Connections, IPs, throughput rates |
|
||||
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
|
||||
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
||||
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
||||
|
||||
### Actions
|
||||
|
||||
@@ -120,6 +146,23 @@ fetchSecurityIncidentsAction() // Security events
|
||||
fetchBounceRecordsAction() // Bounce records
|
||||
resendEmailAction(emailId) // Re-queue failed email
|
||||
removeFromSuppressionAction(email) // Remove from suppression list
|
||||
|
||||
// Certificates
|
||||
fetchCertificateOverviewAction() // All certificates with summary
|
||||
reprovisionCertificateAction(domain) // Reprovision a certificate
|
||||
deleteCertificateAction(domain) // Delete a certificate
|
||||
importCertificateAction(cert) // Import a certificate
|
||||
fetchCertificateExport(domain) // Export (standalone function)
|
||||
|
||||
// Remote Ingress
|
||||
fetchRemoteIngressAction() // Edges + statuses
|
||||
createRemoteIngressAction(data) // Create new edge
|
||||
updateRemoteIngressAction(data) // Update edge settings
|
||||
deleteRemoteIngressAction(id) // Remove edge
|
||||
regenerateRemoteIngressSecretAction(id) // New secret
|
||||
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
||||
clearNewEdgeSecretAction() // Dismiss secret banner
|
||||
fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
||||
```
|
||||
|
||||
### Client-Side Routing
|
||||
@@ -132,6 +175,9 @@ removeFromSuppressionAction(email) // Remove from suppression list
|
||||
/emails/sent → Sent emails
|
||||
/emails/failed → Failed emails
|
||||
/emails/security → Security incidents
|
||||
/certificates → Certificate management
|
||||
/remoteingress → Remote ingress edge management
|
||||
/routes → Route & API token management
|
||||
/logs → Log viewer
|
||||
/configuration → System configuration
|
||||
/security → Security dashboard
|
||||
|
||||
@@ -3,11 +3,9 @@ import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security'] as const;
|
||||
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
export type TValidEmailFolder = typeof validEmailFolders[number];
|
||||
|
||||
class AppRouter {
|
||||
private router: InstanceType<typeof SmartRouter>;
|
||||
@@ -27,32 +25,11 @@ class AppRouter {
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
// Main views
|
||||
for (const view of validViews) {
|
||||
if (view === 'emails') {
|
||||
// Email root - default to queued
|
||||
this.router.on('/emails', async () => {
|
||||
this.updateViewState('emails');
|
||||
this.updateEmailFolder('queued');
|
||||
});
|
||||
|
||||
// Email with folder parameter
|
||||
this.router.on('/emails/:folder', async (routeInfo) => {
|
||||
const folder = routeInfo.params.folder as string;
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.updateViewState('emails');
|
||||
this.updateEmailFolder(folder as TValidEmailFolder);
|
||||
} else {
|
||||
// Invalid folder, redirect to queued
|
||||
this.navigateTo('/emails/queued');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.updateViewState(view);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Root redirect
|
||||
this.router.on('/', async () => {
|
||||
@@ -61,60 +38,32 @@ class AppRouter {
|
||||
}
|
||||
|
||||
private setupStateSync(): void {
|
||||
// Sync URL when state changes programmatically (not from router)
|
||||
appstate.uiStatePart.state.subscribe((uiState) => {
|
||||
if (this.suppressStateUpdate) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const expectedPath = this.getExpectedPath(uiState.activeView);
|
||||
const expectedPath = `/${uiState.activeView}`;
|
||||
|
||||
// Only update URL if it doesn't match current state
|
||||
if (!currentPath.startsWith(expectedPath)) {
|
||||
if (currentPath !== expectedPath) {
|
||||
this.suppressStateUpdate = true;
|
||||
if (uiState.activeView === 'emails') {
|
||||
const emailState = appstate.emailOpsStatePart.getState();
|
||||
this.router.pushUrl(`/emails/${emailState.currentView}`);
|
||||
} else {
|
||||
this.router.pushUrl(`/${uiState.activeView}`);
|
||||
}
|
||||
this.router.pushUrl(expectedPath);
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getExpectedPath(view: string): string {
|
||||
if (view === 'emails') {
|
||||
return '/emails';
|
||||
}
|
||||
return `/${view}`;
|
||||
}
|
||||
|
||||
private handleInitialRoute(): void {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (!path || path === '/') {
|
||||
// Redirect root to overview
|
||||
this.router.pushUrl('/overview');
|
||||
} else {
|
||||
// Parse current path and update state
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
|
||||
if (validViews.includes(view as TValidView)) {
|
||||
this.updateViewState(view as TValidView);
|
||||
|
||||
if (view === 'emails' && segments[1]) {
|
||||
const folder = segments[1];
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.updateEmailFolder(folder as TValidEmailFolder);
|
||||
} else {
|
||||
this.updateEmailFolder('queued');
|
||||
}
|
||||
} else if (view === 'emails') {
|
||||
this.updateEmailFolder('queued');
|
||||
}
|
||||
} else {
|
||||
// Invalid view, redirect to overview
|
||||
this.router.pushUrl('/overview');
|
||||
}
|
||||
}
|
||||
@@ -132,18 +81,6 @@ class AppRouter {
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
private updateEmailFolder(folder: TValidEmailFolder): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.emailOpsStatePart.getState();
|
||||
if (currentState.currentView !== folder) {
|
||||
appstate.emailOpsStatePart.setState({
|
||||
...currentState,
|
||||
currentView: folder as appstate.IEmailOpsState['currentView'],
|
||||
});
|
||||
}
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
public navigateTo(path: string): void {
|
||||
this.router.pushUrl(path);
|
||||
}
|
||||
@@ -156,22 +93,10 @@ class AppRouter {
|
||||
}
|
||||
}
|
||||
|
||||
public navigateToEmailFolder(folder: string): void {
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.navigateTo(`/emails/${folder}`);
|
||||
} else {
|
||||
this.navigateTo('/emails/queued');
|
||||
}
|
||||
}
|
||||
|
||||
public getCurrentView(): string {
|
||||
return appstate.uiStatePart.getState().activeView;
|
||||
}
|
||||
|
||||
public getCurrentEmailFolder(): string {
|
||||
return appstate.emailOpsStatePart.getState().currentView;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.router.destroy();
|
||||
this.initialized = false;
|
||||
|
||||
Reference in New Issue
Block a user