Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3f1f58b67 | |||
| 9e0e77737b | |||
| 5de3344905 | |||
| ae34314f54 | |||
| 5b473de354 | |||
| 1a108fa8b7 | |||
| badabe753a | |||
| c2d3ace0dd |
BIN
.playwright-mcp/dcrouter-scrollbar-issue.png
Normal file
BIN
.playwright-mcp/dcrouter-scrollbar-issue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
.playwright-mcp/page-2026-02-01T23-10-23-737Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-01T23-10-23-737Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
.playwright-mcp/page-2026-02-01T23-11-19-449Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-01T23-11-19-449Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
.playwright-mcp/page-2026-02-01T23-12-03-126Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-01T23-12-03-126Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
.playwright-mcp/page-2026-02-01T23-12-15-576Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-01T23-12-15-576Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
36
changelog.md
36
changelog.md
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-03 - 4.0.0 - BREAKING CHANGE(config)
|
||||
convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing
|
||||
|
||||
- Removed server-side 'updateConfiguration' TypedHandler and the private updateConfiguration() method; getConfiguration remains as a read-only handler.
|
||||
- Removed IReq_UpdateConfiguration interface from request typings; IReq_GetConfiguration marked as read-only.
|
||||
- Removed client-side editing functionality: ops-view-config editing state and methods, Edit/Save/Cancel buttons, and updateConfigurationAction; ops-view-config enhanced to display read-only configuration (badges for booleans, array pills, icons, formatted numbers/bytes, empty states, etc.).
|
||||
- Tests updated: replaced configuration update tests with verifyIdentity tests and added a read-only configuration access test.
|
||||
- Documentation updated to reflect configuration is read-only (readme.md, ts_web/readme.md, ts_interfaces/readme.md, readme.hints.md).
|
||||
- Dependencies adjusted: bumped @push.rocks/smartdata to ^7.0.15 and added @push.rocks/smartmongo ^5.1.0; ts/plugins updated to import/export smartmongo.
|
||||
|
||||
## 2026-02-02 - 3.1.0 - feat(web)
|
||||
determine initial UI view from URL and wire selected view to appdash; add interface and web README files; bump various dependencies
|
||||
|
||||
- UI: derive initial active view from window.location.pathname so the dashboard supports deep linking and bookmarks (ts_web/appstate.ts)
|
||||
- UI: pass selectedView to dees-simple-appdash by adding a currentViewTab getter in ops-dashboard (ts_web/elements/ops-dashboard.ts)
|
||||
- Docs: add TypeScript interfaces README for @serve.zone/dcrouter-interfaces (ts_interfaces/readme.md)
|
||||
- Docs: add/update web module README detailing features, routing, and build instructions (ts_web/readme.md) and expand main project README
|
||||
- Deps: bump multiple dependencies in package.json (notable bumps: @api.global/typedrequest -> ^3.2.5, @design.estate/dees-catalog -> ^3.42.0, @design.estate/dees-element -> ^2.1.6, @push.rocks/projectinfo -> ^5.0.2, @push.rocks/smartdata -> ^5.16.7, @push.rocks/smartpromise -> ^4.2.3, @push.rocks/smartradius -> ^1.1.0, @push.rocks/smartstate -> ^2.0.30, mailauth -> ^4.12.1)
|
||||
|
||||
## 2026-02-01 - 3.0.0 - BREAKING CHANGE(deps)
|
||||
upgrade major dependencies, migrate action.target to action.targets (array), adapt to SmartRequest API changes, and add RADIUS server support
|
||||
|
||||
- Bumped many major dependencies: @api.global/typedserver 3.x → 8.3.0, @api.global/typedsocket 3.x → 4.1.0, @apiclient.xyz/cloudflare 6.x → 7.1.0, @design.estate/dees-catalog 1.x → 3.41.4, @push.rocks/smartpath 5.x → 6.x, @push.rocks/smartproxy 19.x → 22.x, @push.rocks/smartrequest 2.x → 5.x, uuid 11.x → 13.x, @types/node 25.1.0 → 25.2.0
|
||||
|
||||
## 2026-02-01 - 2.13.0 - feat(radius)
|
||||
add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers
|
||||
|
||||
- Introduce full RADIUS module under ts/radius: classes.radius.server, classes.vlan.manager, classes.accounting.manager (authentication, VLAN mapping, OUI patterns, accounting, persistence).
|
||||
- Integrate RADIUS into DcRouter: add radiusConfig option, setupRadiusServer(), updateRadiusConfig(), start/stop lifecycle handling and startup summary output.
|
||||
- Add OpsServer RadiusHandler (ts/opsserver/handlers/radius.handler.ts) exposing TypedRequest endpoints for client management, VLAN mappings, accounting reports and statistics.
|
||||
- Add typed request interfaces for RADIUS under ts_interfaces/requests/radius.ts and export them from the requests index.
|
||||
- Wire smartradius into plugins (ts/plugins.ts) and export the new module; export RADIUS from ts/index.ts and re-export RADIUS types from classes.dcrouter.
|
||||
- Update package.json & npmextra.json: add tswatch script and dev watcher configuration, add @push.rocks/smartradius dependency and a test_watch/devserver.ts dev server entrypoint.
|
||||
- Refactor several web UI components (ops-dashboard, ops-view-*) to use 'accessor' for @state properties (small UI state API adjustments).
|
||||
- Documentation: update readme.hints.md with RADIUS integration notes and examples.
|
||||
|
||||
## 2026-02-01 - 2.12.6 - fix(tests)
|
||||
update tests and test helpers to current email/DNS APIs, use non-privileged ports, and improve robustness and resilience
|
||||
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
"watchers": [
|
||||
{
|
||||
"name": "dcrouter-dev",
|
||||
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
|
||||
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
"runOnStart": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
{
|
||||
|
||||
46
package.json
46
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "2.12.6",
|
||||
"version": "4.0.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
@@ -13,7 +13,8 @@
|
||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
"bundle": "(tsbundle)"
|
||||
"bundle": "(tsbundle)",
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
@@ -21,21 +22,21 @@
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.0.1",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"node-forge": "^1.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.0.19",
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^3.0.74",
|
||||
"@api.global/typedsocket": "^3.0.0",
|
||||
"@apiclient.xyz/cloudflare": "^6.4.1",
|
||||
"@design.estate/dees-catalog": "^1.10.10",
|
||||
"@design.estate/dees-element": "^2.0.45",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.42.0",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^8.0.0",
|
||||
"@push.rocks/smartdata": "^5.15.1",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdns": "^7.6.1",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
@@ -43,23 +44,25 @@
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartmail": "^2.2.0",
|
||||
"@push.rocks/smartmetrics": "^2.0.10",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartpath": "^5.0.5",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartproxy": "^19.6.15",
|
||||
"@push.rocks/smartrequest": "^2.1.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^22.4.2",
|
||||
"@push.rocks/smartradius": "^1.1.0",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrule": "^2.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.27",
|
||||
"@push.rocks/smartstate": "^2.0.30",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"ip": "^2.0.1",
|
||||
"lru-cache": "^11.2.5",
|
||||
"mailauth": "^4.12.0",
|
||||
"mailauth": "^4.12.1",
|
||||
"mailparser": "^3.9.3",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
@@ -80,7 +83,12 @@
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management"
|
||||
"DNS management",
|
||||
"RADIUS",
|
||||
"AAA",
|
||||
"network authentication",
|
||||
"VLAN assignment",
|
||||
"MAC authentication"
|
||||
],
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
431
pnpm-lock.yaml
generated
431
pnpm-lock.yaml
generated
@@ -9,38 +9,38 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@api.global/typedrequest':
|
||||
specifier: ^3.0.19
|
||||
specifier: ^3.2.5
|
||||
version: 3.2.5
|
||||
'@api.global/typedrequest-interfaces':
|
||||
specifier: ^3.0.19
|
||||
version: 3.0.19
|
||||
'@api.global/typedserver':
|
||||
specifier: ^3.0.74
|
||||
version: 3.0.80(@push.rocks/smartserve@2.0.1)
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0(@tiptap/pm@2.27.2)
|
||||
'@api.global/typedsocket':
|
||||
specifier: ^3.0.0
|
||||
version: 3.1.1(@push.rocks/smartserve@2.0.1)
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(@push.rocks/smartserve@2.0.1)
|
||||
'@apiclient.xyz/cloudflare':
|
||||
specifier: ^6.4.1
|
||||
version: 6.4.3
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^1.10.10
|
||||
version: 1.12.4(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.42.0
|
||||
version: 3.42.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.0.45
|
||||
specifier: ^2.1.6
|
||||
version: 2.1.6
|
||||
'@push.rocks/projectinfo':
|
||||
specifier: ^5.0.1
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
'@push.rocks/qenv':
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
'@push.rocks/smartacme':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
|
||||
version: 8.0.0(socks@2.8.7)
|
||||
'@push.rocks/smartdata':
|
||||
specifier: ^5.15.1
|
||||
version: 5.16.7(socks@2.8.7)
|
||||
specifier: ^7.0.15
|
||||
version: 7.0.15(socks@2.8.7)
|
||||
'@push.rocks/smartdns':
|
||||
specifier: ^7.6.1
|
||||
version: 7.6.1
|
||||
@@ -62,21 +62,27 @@ importers:
|
||||
'@push.rocks/smartmetrics':
|
||||
specifier: ^2.0.10
|
||||
version: 2.0.10
|
||||
'@push.rocks/smartmongo':
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(socks@2.8.7)
|
||||
'@push.rocks/smartnetwork':
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
'@push.rocks/smartpath':
|
||||
specifier: ^5.0.5
|
||||
version: 5.1.0
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
'@push.rocks/smartpromise':
|
||||
specifier: ^4.0.3
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^19.6.15
|
||||
version: 19.6.17(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
|
||||
specifier: ^22.4.2
|
||||
version: 22.4.2(socks@2.8.7)
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
'@push.rocks/smartrequest':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
'@push.rocks/smartrule':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -84,8 +90,8 @@ importers:
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10
|
||||
'@push.rocks/smartstate':
|
||||
specifier: ^2.0.27
|
||||
version: 2.0.27
|
||||
specifier: ^2.0.30
|
||||
version: 2.0.30
|
||||
'@push.rocks/smartunique':
|
||||
specifier: ^3.0.9
|
||||
version: 3.0.9
|
||||
@@ -105,14 +111,14 @@ importers:
|
||||
specifier: ^11.2.5
|
||||
version: 11.2.5
|
||||
mailauth:
|
||||
specifier: ^4.12.0
|
||||
version: 4.12.0
|
||||
specifier: ^4.12.1
|
||||
version: 4.12.1
|
||||
mailparser:
|
||||
specifier: ^3.9.3
|
||||
version: 3.9.3
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^4.1.2
|
||||
@@ -130,8 +136,8 @@ importers:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(@tiptap/pm@2.27.2)
|
||||
'@types/node':
|
||||
specifier: ^25.1.0
|
||||
version: 25.1.0
|
||||
specifier: ^25.2.0
|
||||
version: 25.2.0
|
||||
node-forge:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3
|
||||
@@ -169,6 +175,9 @@ packages:
|
||||
'@apiclient.xyz/cloudflare@6.4.3':
|
||||
resolution: {integrity: sha512-ztegUdUO3Zd4mUoTSylKlCEKPBMHEcggrLelR+7CiblM4beHMwopMVlryBmiCY7bOVbUSPoK0xsVTF7VIy3p/A==}
|
||||
|
||||
'@apiclient.xyz/cloudflare@7.1.0':
|
||||
resolution: {integrity: sha512-qb+PWcE5OjOCPO0+4rexOgtEf9Q1VOIHfrGmav/gXAtkdNL5omifSxPbUseyFKsZrxnRv4rLzvjckUCj0hkvFw==}
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -356,11 +365,8 @@ packages:
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
'@design.estate/dees-catalog@1.12.4':
|
||||
resolution: {integrity: sha512-tzNW3b1BQkbE7W2DcwFqXHy+igHzxMHoR+awCAE4/EzHl8kOJc4jF1RNyCe34LsuNaELgZ95Hde/HGgEC8JasA==}
|
||||
|
||||
'@design.estate/dees-catalog@3.41.4':
|
||||
resolution: {integrity: sha512-tVut61OuMF+o/1dzcKEvfom6sl8dMC5WzxIevN9jVq4sQgMO9DOrFd+UfKwRL9FfLZeqAFyxJDzU8389e4Pg0Q==}
|
||||
'@design.estate/dees-catalog@3.42.0':
|
||||
resolution: {integrity: sha512-pArkafnrhRsHsSxKUMUM2YP5ei/AbcchPEKZY2PyHHAdXcNxyT3pE2Oh1FPcs1pqF2LpEgJRq8KFQbFhvhp8Nw==}
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||
@@ -371,9 +377,6 @@ packages:
|
||||
'@design.estate/dees-element@2.1.6':
|
||||
resolution: {integrity: sha512-7zyHkUjB8UEQgT9VbB2IJtc/yuPt9CI5JGel3b6BxA1kecY64ceIjFvof1uIkc0QP8q2fMLLY45r1c+9zDTjzg==}
|
||||
|
||||
'@design.estate/dees-wcctools@1.3.0':
|
||||
resolution: {integrity: sha512-+yd8c1gTIKNRQYCvG0xu6Am8dHsRm7ymluX2gnoBQN4aFOpZgIBi/v9CvGyPhTD1p/VRouIBz1wsUCejnwrFCA==}
|
||||
|
||||
'@design.estate/dees-wcctools@3.8.0':
|
||||
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
||||
|
||||
@@ -934,6 +937,9 @@ packages:
|
||||
'@push.rocks/smartdata@5.16.7':
|
||||
resolution: {integrity: sha512-bu/YSIjQcwxWXkAsuhqE6zs7eT+bTIKV8+/H7TbbjpzeioLCyB3dZ/41cLZk37c/EYt4d4GHgZ0ww80OiKOUMg==}
|
||||
|
||||
'@push.rocks/smartdata@7.0.15':
|
||||
resolution: {integrity: sha512-j09BUekmjiGZuvXmdGBiIpBTXFFnxrzG4rOBjZvPO/hG1BwNrvSkIVq20mIwdYomn8JGgya6oJ4Y7NL+FKTqEA==}
|
||||
|
||||
'@push.rocks/smartdelay@3.0.5':
|
||||
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
|
||||
|
||||
@@ -991,6 +997,9 @@ packages:
|
||||
'@push.rocks/smartjson@5.2.0':
|
||||
resolution: {integrity: sha512-710e8UwovRfPgUtaBHcd6unaODUjV5fjxtGcGCqtaTcmvOV6VpasdVfT66xMDzQmWH2E9ZfHDJeso9HdDQzNQA==}
|
||||
|
||||
'@push.rocks/smartjson@6.0.0':
|
||||
resolution: {integrity: sha512-FYfJnmukt66WePn6xrVZ3BLmRQl9W82LcsICK3VU9sGW7kasig090jKXPm+yX8ibQcZAO/KyR/Q8tMIYZNxGew==}
|
||||
|
||||
'@push.rocks/smartjwt@2.2.1':
|
||||
resolution: {integrity: sha512-Xwau9o8u7kLfSGi5v+kiyGB/hiDPclZjVEuj69J0LszO9nOh4OexYizKIOgOzKQMqnYQ03Dy35KqP9pdEjccbQ==}
|
||||
|
||||
@@ -1030,6 +1039,9 @@ packages:
|
||||
'@push.rocks/smartmongo@2.2.0':
|
||||
resolution: {integrity: sha512-ovVCNoJ3D0aBuKtoKaQWWQKvBngaGJq9fAPQigzji1EHsS1XyGpXWCpe5nq/ptGvBROOcpqZcOFEGAcrnb+OjA==}
|
||||
|
||||
'@push.rocks/smartmongo@5.1.0':
|
||||
resolution: {integrity: sha512-2tpKf8K+SMdLHOEpafgKPIN+ypWTLwHc33hCUDNMQ1KaL7vokkavA44+fHxQydOGPMtDi22tSMFeVMCcUSzs4w==}
|
||||
|
||||
'@push.rocks/smartmustache@3.0.2':
|
||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||
|
||||
@@ -1063,12 +1075,15 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.3':
|
||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||
|
||||
'@push.rocks/smartproxy@19.6.17':
|
||||
resolution: {integrity: sha512-5y6lVxlHXoVQXAQLr5S+2ifxZf9EID32twyeuZTS9tDyof0wJKppLzKQepwB7hfQXS2J06JBN7oa9n0mguELBg==}
|
||||
'@push.rocks/smartproxy@22.4.2':
|
||||
resolution: {integrity: sha512-JdDa1VxGOnWfF5HuJRvkX3/zHuIKz+IV9n/XOsNZQA9zMZdLVlWPqjGio9GLWsPOWA2l1YZKymjMH4ybPbGQtA==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5':
|
||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||
|
||||
'@push.rocks/smartradius@1.1.0':
|
||||
resolution: {integrity: sha512-eocddp/bDcB5a/JOt5lezz0uBWezOKpnDQgMx+I4bl8eJ20KIWh0B6PhYuKYjGuDwo/t01p+s+m0gG7IgyPmzQ==}
|
||||
|
||||
'@push.rocks/smartrequest@2.1.0':
|
||||
resolution: {integrity: sha512-3eHLTRInHA+u+W98TqJwgTES7rRimBAsJC4JxVNQC3UUezmblAhM5/TIQsEBQTsbjAY8SeQKy6NHzW6iTiaD8w==}
|
||||
|
||||
@@ -1105,8 +1120,8 @@ packages:
|
||||
'@push.rocks/smartspawn@3.0.3':
|
||||
resolution: {integrity: sha512-DyrGPV69wwOiJgKkyruk5hS3UEGZ99xFAqBE9O2nM8VXCRLbbty3xt1Ug5Z092ZZmJYaaGMSnMw3ijyZJFCT0Q==}
|
||||
|
||||
'@push.rocks/smartstate@2.0.27':
|
||||
resolution: {integrity: sha512-q4UKir7GV3hakJWXQR4DoA4tUVwT5GRkJ/MtanHYF0wZLHfS19+nGmyO9y974zk3eT9hmy3+Lq5cKtU2W6+Y3w==}
|
||||
'@push.rocks/smartstate@2.0.30':
|
||||
resolution: {integrity: sha512-IuNW8XtSumXIr7g7MIFyWg5PBwLF2mwsymTJbSEycK2Pa9ZLk4yjRHnR907xCilxgiMU9ixQZyNdpa5MMF999A==}
|
||||
|
||||
'@push.rocks/smartstream@2.0.8':
|
||||
resolution: {integrity: sha512-GlF/9cCkvBHwKa3DK4DO5wjfSgqkj6gAS4TrY9uD5NMHu9RQv4WiNrElTYj7iCEpnZgUnLO3tzw1JA3NRIMnnA==}
|
||||
@@ -1870,6 +1885,10 @@ packages:
|
||||
'@types/minimatch@5.1.2':
|
||||
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
|
||||
|
||||
'@types/minimatch@6.0.0':
|
||||
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
|
||||
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
@@ -1888,8 +1907,8 @@ packages:
|
||||
'@types/node@22.19.7':
|
||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||
|
||||
'@types/node@25.1.0':
|
||||
resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==}
|
||||
'@types/node@25.2.0':
|
||||
resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==}
|
||||
|
||||
'@types/pidusage@2.0.5':
|
||||
resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==}
|
||||
@@ -1948,6 +1967,9 @@ packages:
|
||||
'@types/whatwg-url@11.0.5':
|
||||
resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
|
||||
|
||||
'@types/whatwg-url@13.0.0':
|
||||
resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
|
||||
|
||||
'@types/which@3.0.4':
|
||||
resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==}
|
||||
|
||||
@@ -1963,9 +1985,6 @@ packages:
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@webcontainer/api@1.2.0':
|
||||
resolution: {integrity: sha512-tzoKBd4lLdhHy5GHFpUkl+ndoSba8JqmB7x0ZQFnWfjbcbQOvKQfxA8MEMUYhgqjWHnbrWdAfnBEHz5f5lYG5A==}
|
||||
|
||||
'@yr/monotone-cubic-spline@1.0.3':
|
||||
resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==}
|
||||
|
||||
@@ -2138,6 +2157,10 @@ packages:
|
||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
|
||||
bson@7.1.1:
|
||||
resolution: {integrity: sha512-TtJgBB+QyOlWjrbM+8bRgH84VM/xrDjyBFgSgGrfZF4xvt6gbEDtcswm27Tn9F9TWsjQybxT8b8VpCP/oJK4Dw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
|
||||
|
||||
@@ -2566,10 +2589,6 @@ packages:
|
||||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
fast-xml-parser@4.5.2:
|
||||
resolution: {integrity: sha512-xmnYV9o0StIz/0ArdzmWTxn9oDy0lH8Z80/8X/TD2EUQKXY4DHxoT9mYBqgGIG17DgddCJtH1M6DriMbalNsAA==}
|
||||
hasBin: true
|
||||
|
||||
fast-xml-parser@4.5.3:
|
||||
resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==}
|
||||
hasBin: true
|
||||
@@ -3091,14 +3110,11 @@ packages:
|
||||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lucide@0.544.0:
|
||||
resolution: {integrity: sha512-U5ORwr5z9Sx7bNTDFaW55RbjVdQEnAcT3vws9uz3vRT1G4XXJUDAhRZdxhFoIyHEvjmTkzzlEhjSLYM5n4mb5w==}
|
||||
|
||||
lucide@0.563.0:
|
||||
resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==}
|
||||
|
||||
mailauth@4.12.0:
|
||||
resolution: {integrity: sha512-2fMtvJBbXV32NlD6f6BSRTLNPsC1UgA+oj4mtAIUzzZlTQF2oNUgWmRQFKf1Ui9ad6KcadF9oqYrl7sCTHBNag==}
|
||||
mailauth@4.12.1:
|
||||
resolution: {integrity: sha512-mSbMST+YUKj4WAfVvVszw/lnqxYA9AsYX6jYdl9vQdgz1vP5gIMwK6/RcqY+CkMkfkhFzea5+72asj620eAHmQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3337,15 +3353,16 @@ packages:
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
monaco-editor@0.52.2:
|
||||
resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
|
||||
|
||||
mongodb-connection-string-url@3.0.2:
|
||||
resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==}
|
||||
|
||||
mongodb-connection-string-url@7.0.1:
|
||||
resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
mongodb-memory-server-core@10.4.3:
|
||||
resolution: {integrity: sha512-IPjlw73IoSYopnqBibQKxmAXMbOEPf5uGAOsBcaUiNH/TOI7V19WO+K7n5KYtnQ9FqzLGLpvwCGuPOTBSg4s5Q==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
@@ -3381,6 +3398,33 @@ packages:
|
||||
socks:
|
||||
optional: true
|
||||
|
||||
mongodb@7.0.0:
|
||||
resolution: {integrity: sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@aws-sdk/credential-providers': ^3.806.0
|
||||
'@mongodb-js/zstd': ^7.0.0
|
||||
gcp-metadata: ^7.0.1
|
||||
kerberos: ^7.0.0
|
||||
mongodb-client-encryption: '>=7.0.0 <7.1.0'
|
||||
snappy: ^7.3.2
|
||||
socks: ^2.8.6
|
||||
peerDependenciesMeta:
|
||||
'@aws-sdk/credential-providers':
|
||||
optional: true
|
||||
'@mongodb-js/zstd':
|
||||
optional: true
|
||||
gcp-metadata:
|
||||
optional: true
|
||||
kerberos:
|
||||
optional: true
|
||||
mongodb-client-encryption:
|
||||
optional: true
|
||||
snappy:
|
||||
optional: true
|
||||
socks:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -3432,10 +3476,6 @@ packages:
|
||||
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
||||
engines: {node: '>= 6.13.0'}
|
||||
|
||||
nodemailer@7.0.11:
|
||||
resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
nodemailer@7.0.13:
|
||||
resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -4058,8 +4098,8 @@ packages:
|
||||
tldts-core@7.0.21:
|
||||
resolution: {integrity: sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==}
|
||||
|
||||
tldts@7.0.19:
|
||||
resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
|
||||
tldts@7.0.21:
|
||||
resolution: {integrity: sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==}
|
||||
hasBin: true
|
||||
|
||||
tmp@0.0.33:
|
||||
@@ -4157,8 +4197,8 @@ packages:
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
undici@7.16.0:
|
||||
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||
undici@7.19.2:
|
||||
resolution: {integrity: sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unified@11.0.5:
|
||||
@@ -4200,8 +4240,8 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=}
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
uuid@13.0.0:
|
||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
@@ -4432,7 +4472,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1)
|
||||
'@cloudflare/workers-types': 4.20260131.0
|
||||
'@design.estate/dees-catalog': 3.41.4(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.42.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -4479,7 +4519,7 @@ snapshots:
|
||||
'@push.rocks/isohash': 2.0.1
|
||||
'@push.rocks/smartjson': 5.2.0
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smartsocket': 2.1.0(@push.rocks/smartserve@2.0.1)
|
||||
'@push.rocks/smartsocket': 2.1.0
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@push.rocks/smarturl': 3.1.0
|
||||
optionalDependencies:
|
||||
@@ -4517,6 +4557,18 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@apiclient.xyz/cloudflare@7.1.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.10
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrequest': 5.0.1
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
cloudflare: 5.2.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
@@ -5022,43 +5074,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
'@design.estate/dees-catalog@1.12.4(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.3.8
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
'@design.estate/dees-wcctools': 1.3.0
|
||||
'@fortawesome/fontawesome-svg-core': 7.1.0
|
||||
'@fortawesome/free-brands-svg-icons': 7.1.0
|
||||
'@fortawesome/free-regular-svg-icons': 7.1.0
|
||||
'@fortawesome/free-solid-svg-icons': 7.1.0
|
||||
'@push.rocks/smarti18n': 1.0.4
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
|
||||
'@tiptap/extension-link': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)
|
||||
'@tiptap/extension-text-align': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
|
||||
'@tiptap/extension-typography': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
|
||||
'@tiptap/extension-underline': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
|
||||
'@tiptap/starter-kit': 2.27.2
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
'@webcontainer/api': 1.2.0
|
||||
apexcharts: 5.3.6
|
||||
highlight.js: 11.11.1
|
||||
ibantools: 4.5.1
|
||||
lit: 3.3.2
|
||||
lucide: 0.544.0
|
||||
monaco-editor: 0.52.2
|
||||
pdfjs-dist: 4.10.38
|
||||
xterm: 5.3.0
|
||||
xterm-addon-fit: 0.8.0(xterm@5.3.0)
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- '@tiptap/pm'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@design.estate/dees-catalog@3.41.4(@tiptap/pm@2.27.2)':
|
||||
'@design.estate/dees-catalog@3.42.0(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.3.8
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
@@ -5111,7 +5127,7 @@ snapshots:
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrouter': 1.3.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smartstate': 2.0.27
|
||||
'@push.rocks/smartstate': 2.0.30
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@push.rocks/smarturl': 3.1.0
|
||||
'@push.rocks/webrequest': 3.0.37
|
||||
@@ -5138,18 +5154,6 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@design.estate/dees-wcctools@1.3.0':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.3.8
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
lit: 3.3.2
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@design.estate/dees-wcctools@3.8.0':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.3.8
|
||||
@@ -5914,7 +5918,7 @@ snapshots:
|
||||
'@push.rocks/smartlog': 3.1.10
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
|
||||
'@push.rocks/smartacme@8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)':
|
||||
'@push.rocks/smartacme@8.0.0(socks@2.8.7)':
|
||||
dependencies:
|
||||
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
|
||||
'@apiclient.xyz/cloudflare': 6.4.3
|
||||
@@ -5936,7 +5940,6 @@ snapshots:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- '@push.rocks/smartserve'
|
||||
- bare-abort-controller
|
||||
- encoding
|
||||
- gcp-metadata
|
||||
@@ -6086,6 +6089,35 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/smartdata@7.0.15(socks@2.8.7)':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.10
|
||||
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
'@push.rocks/taskbuffer': 3.5.0
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
mongodb: 7.0.0(socks@2.8.7)
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- bare-abort-controller
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- react
|
||||
- react-native-b4a
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/smartdelay@3.0.5':
|
||||
dependencies:
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
@@ -6242,6 +6274,13 @@ snapshots:
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
lodash.clonedeep: 4.5.0
|
||||
|
||||
'@push.rocks/smartjson@6.0.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartenv': 6.0.0
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
lodash.clonedeep: 4.5.0
|
||||
|
||||
'@push.rocks/smartjwt@2.2.1':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
@@ -6356,6 +6395,32 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/smartmongo@5.1.0(socks@2.8.7)':
|
||||
dependencies:
|
||||
'@push.rocks/mongodump': 1.1.0(socks@2.8.7)
|
||||
'@push.rocks/smartdata': 5.16.7(socks@2.8.7)
|
||||
'@push.rocks/smartfs': 1.3.1
|
||||
'@push.rocks/smartpath': 5.1.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
bson: 6.10.4
|
||||
mingo: 7.2.0
|
||||
mongodb-memory-server: 10.4.3(socks@2.8.7)
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- bare-abort-controller
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- react
|
||||
- react-native-b4a
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/smartmustache@3.0.2':
|
||||
dependencies:
|
||||
handlebars: 4.7.8
|
||||
@@ -6448,22 +6513,22 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.3': {}
|
||||
|
||||
'@push.rocks/smartproxy@19.6.17(@push.rocks/smartserve@2.0.1)(socks@2.8.7)':
|
||||
'@push.rocks/smartproxy@22.4.2(socks@2.8.7)':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartacme': 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
|
||||
'@push.rocks/smartacme': 8.0.0(socks@2.8.7)
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smartfile': 13.1.2
|
||||
'@push.rocks/smartlog': 3.1.10
|
||||
'@push.rocks/smartnetwork': 4.4.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrequest': 2.1.0
|
||||
'@push.rocks/smartrequest': 5.0.1
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@push.rocks/taskbuffer': 3.5.0
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/minimatch': 6.0.0
|
||||
'@types/ws': 8.18.1
|
||||
minimatch: 10.1.1
|
||||
pretty-ms: 9.3.0
|
||||
@@ -6472,7 +6537,6 @@ snapshots:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- '@push.rocks/smartserve'
|
||||
- bare-abort-controller
|
||||
- bufferutil
|
||||
- encoding
|
||||
@@ -6502,6 +6566,11 @@ snapshots:
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
'@push.rocks/smartradius@1.1.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smartrequest@2.1.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
@@ -6581,7 +6650,7 @@ snapshots:
|
||||
'@push.rocks/webrequest': 4.0.1
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
|
||||
'@push.rocks/smartsocket@2.1.0(@push.rocks/smartserve@2.0.1)':
|
||||
'@push.rocks/smartsocket@2.1.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
|
||||
@@ -6600,7 +6669,6 @@ snapshots:
|
||||
socket.io-client: 4.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- '@push.rocks/smartserve'
|
||||
- bufferutil
|
||||
- react
|
||||
- supports-color
|
||||
@@ -6616,11 +6684,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@push.rocks/smartstate@2.0.27':
|
||||
'@push.rocks/smartstate@2.0.30':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smarthash': 3.2.6
|
||||
'@push.rocks/smartjson': 5.2.0
|
||||
'@push.rocks/smartjson': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/webstore': 2.0.20
|
||||
@@ -7451,27 +7519,27 @@ snapshots:
|
||||
|
||||
'@types/bn.js@5.2.0':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/buffer-json@2.0.3': {}
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
@@ -7479,7 +7547,7 @@ snapshots:
|
||||
|
||||
'@types/dns-packet@5.6.5':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/elliptic@6.4.18':
|
||||
dependencies:
|
||||
@@ -7487,7 +7555,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@@ -7500,17 +7568,17 @@ snapshots:
|
||||
|
||||
'@types/from2@2.3.6':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/glob@8.1.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@@ -7532,18 +7600,18 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/mailparser@3.4.6':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
iconv-lite: 0.6.3
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
@@ -7561,20 +7629,24 @@ snapshots:
|
||||
|
||||
'@types/minimatch@5.1.2': {}
|
||||
|
||||
'@types/minimatch@6.0.0':
|
||||
dependencies:
|
||||
minimatch: 10.1.1
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
@@ -7584,7 +7656,7 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.1.0':
|
||||
'@types/node@25.2.0':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
@@ -7604,22 +7676,22 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/symbol-tree@3.2.5': {}
|
||||
|
||||
'@types/tar-stream@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -7639,23 +7711,25 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/webidl-conversions': 7.0.3
|
||||
|
||||
'@types/whatwg-url@13.0.0':
|
||||
dependencies:
|
||||
'@types/webidl-conversions': 7.0.3
|
||||
|
||||
'@types/which@3.0.4': {}
|
||||
|
||||
'@types/wrap-ansi@3.0.0': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@webcontainer/api@1.2.0': {}
|
||||
|
||||
'@yr/monotone-cubic-spline@1.0.3': {}
|
||||
|
||||
'@zone-eu/mailsplit@5.4.8':
|
||||
@@ -7838,6 +7912,8 @@ snapshots:
|
||||
|
||||
bson@6.10.4: {}
|
||||
|
||||
bson@7.1.1: {}
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
@@ -8135,7 +8211,7 @@ snapshots:
|
||||
engine.io@6.6.4:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 25.1.0
|
||||
'@types/node': 25.2.0
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
@@ -8301,10 +8377,6 @@ snapshots:
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-xml-parser@4.5.2:
|
||||
dependencies:
|
||||
strnum: 1.1.2
|
||||
|
||||
fast-xml-parser@4.5.3:
|
||||
dependencies:
|
||||
strnum: 1.1.2
|
||||
@@ -8907,21 +8979,19 @@ snapshots:
|
||||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
lucide@0.544.0: {}
|
||||
|
||||
lucide@0.563.0: {}
|
||||
|
||||
mailauth@4.12.0:
|
||||
mailauth@4.12.1:
|
||||
dependencies:
|
||||
'@postalsys/vmc': 1.1.2
|
||||
fast-xml-parser: 4.5.2
|
||||
fast-xml-parser: 5.3.4
|
||||
ipaddr.js: 2.3.0
|
||||
joi: 18.0.2
|
||||
libmime: 5.3.7
|
||||
nodemailer: 7.0.11
|
||||
nodemailer: 7.0.13
|
||||
punycode.js: 2.3.1
|
||||
tldts: 7.0.19
|
||||
undici: 7.16.0
|
||||
tldts: 7.0.21
|
||||
undici: 7.19.2
|
||||
yargs: 17.7.2
|
||||
|
||||
mailparser@3.9.3:
|
||||
@@ -9341,8 +9411,6 @@ snapshots:
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
monaco-editor@0.52.2: {}
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.2.7
|
||||
@@ -9353,6 +9421,11 @@ snapshots:
|
||||
'@types/whatwg-url': 11.0.5
|
||||
whatwg-url: 14.2.0
|
||||
|
||||
mongodb-connection-string-url@7.0.1:
|
||||
dependencies:
|
||||
'@types/whatwg-url': 13.0.0
|
||||
whatwg-url: 14.2.0
|
||||
|
||||
mongodb-memory-server-core@10.4.3(socks@2.8.7):
|
||||
dependencies:
|
||||
async-mutex: 0.5.0
|
||||
@@ -9403,6 +9476,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
socks: 2.8.7
|
||||
|
||||
mongodb@7.0.0(socks@2.8.7):
|
||||
dependencies:
|
||||
'@mongodb-js/saslprep': 1.4.5
|
||||
bson: 7.1.1
|
||||
mongodb-connection-string-url: 7.0.1
|
||||
optionalDependencies:
|
||||
socks: 2.8.7
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mute-stream@1.0.0: {}
|
||||
@@ -9435,8 +9516,6 @@ snapshots:
|
||||
|
||||
node-forge@1.3.3: {}
|
||||
|
||||
nodemailer@7.0.11: {}
|
||||
|
||||
nodemailer@7.0.13: {}
|
||||
|
||||
normalize-newline@4.1.0:
|
||||
@@ -10234,7 +10313,7 @@ snapshots:
|
||||
|
||||
tldts-core@7.0.21: {}
|
||||
|
||||
tldts@7.0.19:
|
||||
tldts@7.0.21:
|
||||
dependencies:
|
||||
tldts-core: 7.0.21
|
||||
|
||||
@@ -10311,7 +10390,7 @@ snapshots:
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@7.16.0: {}
|
||||
undici@7.19.2: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
@@ -10361,7 +10440,7 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
uuid@13.0.0: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
|
||||
243
readme.hints.md
243
readme.hints.md
@@ -1,5 +1,128 @@
|
||||
# Implementation Hints and Learnings
|
||||
|
||||
## Dependency Upgrade (2026-02-01)
|
||||
|
||||
### Major Upgrades Completed
|
||||
- `@api.global/typedserver`: 3.0.80 → 8.3.0
|
||||
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
|
||||
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
|
||||
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
|
||||
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
|
||||
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
|
||||
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
|
||||
- `uuid`: 11.1.0 → 13.0.0
|
||||
|
||||
### Breaking Changes Fixed
|
||||
|
||||
1. **SmartProxy v22**: `target` → `targets` (array)
|
||||
```typescript
|
||||
// Old
|
||||
action: { type: 'forward', target: { host: 'x', port: 25 } }
|
||||
// New
|
||||
action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
|
||||
```
|
||||
|
||||
2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
|
||||
```typescript
|
||||
// Old
|
||||
const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
|
||||
const json = resp.body;
|
||||
// New
|
||||
const resp = await plugins.smartrequest.SmartRequest.create()...post();
|
||||
const json = await resp.json();
|
||||
```
|
||||
|
||||
3. **dees-catalog v3**: Icon naming changed to library-prefixed format
|
||||
```typescript
|
||||
// Old (deprecated but supported)
|
||||
<dees-icon iconFA="check"></dees-icon>
|
||||
// New
|
||||
<dees-icon icon="fa:check"></dees-icon>
|
||||
<dees-icon icon="lucide:menu"></dees-icon>
|
||||
```
|
||||
|
||||
### TC39 Decorators
|
||||
- ts_web components updated to use `accessor` keyword for `@state()` decorators
|
||||
- Required for TC39 standard decorator support
|
||||
|
||||
### tswatch Configuration
|
||||
The project now uses tswatch for development:
|
||||
```bash
|
||||
pnpm run watch
|
||||
```
|
||||
Configuration in `npmextra.json`:
|
||||
```json
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
"watchers": [{
|
||||
"name": "dcrouter-dev",
|
||||
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
|
||||
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
"runOnStart": true
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## RADIUS Server Integration (2026-02-01)
|
||||
|
||||
### Overview
|
||||
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.
|
||||
|
||||
### Key Features
|
||||
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
|
||||
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
|
||||
- **RADIUS Accounting** - Track sessions, data usage, and billing
|
||||
|
||||
### Configuration Example
|
||||
```typescript
|
||||
const dcRouter = new DcRouter({
|
||||
radiusConfig: {
|
||||
authPort: 1812, // Authentication port (default)
|
||||
acctPort: 1813, // Accounting port (default)
|
||||
clients: [
|
||||
{
|
||||
name: 'switch-1',
|
||||
ipRange: '192.168.1.0/24',
|
||||
secret: 'shared-secret',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
vlanAssignment: {
|
||||
defaultVlan: 100, // VLAN for unknown MACs
|
||||
allowUnknownMacs: true,
|
||||
mappings: [
|
||||
{ mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
|
||||
{ mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern
|
||||
]
|
||||
},
|
||||
accounting: {
|
||||
enabled: true,
|
||||
retentionDays: 30
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Components
|
||||
- `RadiusServer` - Main server wrapping smartradius
|
||||
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
|
||||
- `AccountingManager` - Session tracking and billing data
|
||||
|
||||
### OpsServer API Endpoints
|
||||
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
|
||||
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
|
||||
- `testVlanAssignment` - Test what VLAN a MAC would get
|
||||
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
|
||||
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics
|
||||
|
||||
### Files
|
||||
- `ts/radius/` - RADIUS module
|
||||
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
|
||||
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces
|
||||
|
||||
## Test Fix: test.dcrouter.email.ts (2026-02-01)
|
||||
|
||||
### Issue
|
||||
@@ -1074,3 +1197,123 @@ The throughput was showing 0 because:
|
||||
4. Updated frontend to call the new endpoint for complete network metrics
|
||||
|
||||
The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.
|
||||
|
||||
## Email Operations Dashboard (2026-02-01)
|
||||
|
||||
### Overview
|
||||
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.
|
||||
|
||||
### New Files Created
|
||||
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
|
||||
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations
|
||||
|
||||
### Key Interfaces
|
||||
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
|
||||
- `IReq_GetSentEmails` - Fetch delivered emails
|
||||
- `IReq_GetFailedEmails` - Fetch failed emails
|
||||
- `IReq_ResendEmail` - Re-queue a failed email for retry
|
||||
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
|
||||
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
|
||||
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list
|
||||
|
||||
### UI Changes (ops-view-emails.ts)
|
||||
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
|
||||
- **Queued**: Emails pending delivery
|
||||
- **Sent**: Successfully delivered emails
|
||||
- **Failed**: Failed emails with resend capability
|
||||
- **Security**: Security incidents from SecurityLogger
|
||||
- Removed `generateMockEmails()` method
|
||||
- Added state management via `emailOpsStatePart` in appstate.ts
|
||||
- Added resend button for failed emails
|
||||
- Added security incident detail view
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
```
|
||||
|
||||
### Backend Data Access
|
||||
The handler accesses data from:
|
||||
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
|
||||
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
|
||||
- `emailServer.bounceManager` - Bounce records and suppression list
|
||||
|
||||
## OpsServer UI Fixes (2026-02-02)
|
||||
|
||||
### Configuration Page Fix
|
||||
The configuration page had field name mismatches between frontend and backend:
|
||||
- Frontend expected `server` and `storage` sections
|
||||
- Backend returns `proxy` section (not `server`)
|
||||
- Backend has no `storage` section
|
||||
|
||||
**Fix**: Updated `ops-view-config.ts` to use correct section names:
|
||||
- `proxy` instead of `server`
|
||||
- Removed non-existent `storage` section
|
||||
- Added optional chaining (`?.`) for safety
|
||||
|
||||
### Auth Persistence Fix
|
||||
Login state was using `'soft'` mode in Smartstate which is memory-only:
|
||||
- User login was lost on page refresh
|
||||
- State reset to logged out after browser restart
|
||||
|
||||
**Changes**:
|
||||
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
|
||||
- Now uses IndexedDB to persist across browser sessions
|
||||
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
|
||||
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
|
||||
- Validates stored JWT hasn't expired before auto-logging in
|
||||
- Clears expired sessions and shows login form
|
||||
|
||||
## Config UI Read-Only Conversion (2026-02-03)
|
||||
|
||||
### Overview
|
||||
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
|
||||
- Removed `updateConfiguration` handler
|
||||
- Removed `updateConfiguration()` private method
|
||||
- Kept `getConfiguration` handler (read-only)
|
||||
|
||||
2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
|
||||
- Removed `IReq_UpdateConfiguration` interface
|
||||
- Kept `IReq_GetConfiguration` interface
|
||||
|
||||
3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
|
||||
- Removed `editingSection` and `editedConfig` state properties
|
||||
- Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
|
||||
- Removed Edit/Save/Cancel buttons
|
||||
- Removed warning banner about immediate changes
|
||||
- Enhanced read-only display with:
|
||||
- Status badges for boolean values (enabled/disabled)
|
||||
- Array display as pills/tags with counts
|
||||
- Section icons (mail, globe, network, shield)
|
||||
- Better formatting for numbers and byte sizes
|
||||
- Empty state handling ("Not configured", "None configured")
|
||||
- Info note explaining configuration is read-only
|
||||
|
||||
4. **State Management (`ts_web/appstate.ts`)**:
|
||||
- Removed `updateConfigurationAction`
|
||||
- Kept `fetchConfigurationAction` (read-only)
|
||||
|
||||
5. **Tests (`test/test.protected-endpoint.ts`)**:
|
||||
- Replaced `updateConfiguration` tests with `verifyIdentity` tests
|
||||
- Added test for read-only config access
|
||||
- Kept auth flow testing with different protected endpoint
|
||||
|
||||
6. **Documentation**:
|
||||
- `readme.md`: Updated API endpoints to show config as read-only
|
||||
- `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
|
||||
- `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table
|
||||
|
||||
### Visual Display Features
|
||||
- Boolean values shown as colored badges (green=enabled, red=disabled)
|
||||
- Arrays displayed as pills with count summaries
|
||||
- Section headers with relevant Lucide icons
|
||||
- Numbers formatted with locale separators
|
||||
- Byte sizes auto-formatted (B, KB, MB, GB)
|
||||
- Time values shown with "seconds" suffix
|
||||
- Nested objects with visual indentation
|
||||
545
readme.md
545
readme.md
@@ -1,10 +1,14 @@
|
||||
# dcrouter
|
||||
# @serve.zone/dcrouter
|
||||
|
||||

|
||||
|
||||
**dcrouter: a traffic router intended to be gating your datacenter.**
|
||||
**dcrouter: A powerful traffic router designed to be the gateway for your datacenter.** 🚀
|
||||
|
||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), and DNS protocols. Designed for enterprises requiring robust traffic management, automatic certificate provisioning, and enterprise-grade email infrastructure.
|
||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS protocols, and RADIUS authentication. Designed for enterprises requiring robust traffic management, automatic certificate provisioning, and enterprise-grade email infrastructure.
|
||||
|
||||
## 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.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -13,11 +17,16 @@ A comprehensive traffic routing solution that provides unified gateway capabilit
|
||||
- [Quick Start](#quick-start)
|
||||
- [Architecture](#architecture)
|
||||
- [Configuration](#configuration)
|
||||
- [Socket-Handler Mode](#socket-handler-mode)
|
||||
- [Email System](#email-system)
|
||||
- [SmartProxy Routing](#smartproxy-routing)
|
||||
- [RADIUS Server](#radius-server)
|
||||
- [Storage System](#storage-system)
|
||||
- [Security Features](#security-features)
|
||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||
- [API Reference](#api-reference)
|
||||
- [Examples](#examples)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Features
|
||||
@@ -36,9 +45,16 @@ A comprehensive traffic routing solution that provides unified gateway capabilit
|
||||
|
||||
### 📧 **Complete Email Infrastructure**
|
||||
- **Multi-domain SMTP server** on standard ports (25, 587, 465)
|
||||
- **Pattern-based email routing** with three processing modes
|
||||
- **Pattern-based email routing** with four processing modes (forward, process, deliver, reject)
|
||||
- **DKIM, SPF, DMARC** authentication and verification
|
||||
- **Enterprise deliverability** with IP warmup and reputation management
|
||||
- **Bounce handling** with suppression lists
|
||||
|
||||
### 📡 **RADIUS Server**
|
||||
- **MAC Authentication Bypass (MAB)** for network device authentication
|
||||
- **VLAN assignment** based on MAC address or OUI patterns
|
||||
- **RADIUS accounting** for session tracking and billing
|
||||
- **OpsServer API integration** for real-time management
|
||||
|
||||
### ⚡ **High Performance**
|
||||
- **Connection pooling** and efficient resource management
|
||||
@@ -52,10 +68,18 @@ A comprehensive traffic routing solution that provides unified gateway capabilit
|
||||
- **Automatic data migration** between backends
|
||||
- **Persistent configuration** for domains, routes, and security data
|
||||
|
||||
### 🖥️ **OpsServer Dashboard**
|
||||
- **Web-based management interface** for real-time monitoring
|
||||
- **JWT authentication** with secure admin access
|
||||
- **Live statistics** for connections, email, DNS, and RADIUS
|
||||
- **Configuration management** via TypedRequest API
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @serve.zone/dcrouter --save
|
||||
# or
|
||||
pnpm add @serve.zone/dcrouter
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
@@ -79,7 +103,7 @@ const router = new DcRouter({
|
||||
match: { domains: ['example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: '192.168.1.10', port: 8080 },
|
||||
targets: [{ host: '192.168.1.10', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
}
|
||||
@@ -129,6 +153,30 @@ const router = new DcRouter({
|
||||
await router.start();
|
||||
```
|
||||
|
||||
### With OpsServer Dashboard
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
// Enable OpsServer for web dashboard
|
||||
opsServerConfig: {
|
||||
port: 3000,
|
||||
admin: {
|
||||
username: 'admin',
|
||||
password: 'your-secure-password'
|
||||
}
|
||||
},
|
||||
|
||||
// Your routing configuration...
|
||||
smartProxyConfig: { /* ... */ },
|
||||
emailConfig: { /* ... */ }
|
||||
});
|
||||
|
||||
await router.start();
|
||||
// Dashboard available at http://localhost:3000
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Overview
|
||||
@@ -140,6 +188,7 @@ graph TB
|
||||
SMTP[SMTP Clients]
|
||||
TCP[TCP Clients]
|
||||
DNS[DNS Queries]
|
||||
RADIUS[RADIUS Clients]
|
||||
end
|
||||
|
||||
subgraph "DcRouter Core"
|
||||
@@ -147,7 +196,9 @@ graph TB
|
||||
SmartProxy[SmartProxy Engine]
|
||||
EmailServer[Unified Email Server]
|
||||
DnsServer[DNS Server]
|
||||
RadiusServer[RADIUS Server]
|
||||
CertManager[Certificate Manager]
|
||||
OpsServer[OpsServer Dashboard]
|
||||
end
|
||||
|
||||
subgraph "Backend Services"
|
||||
@@ -161,11 +212,14 @@ graph TB
|
||||
TCP --> SmartProxy
|
||||
SMTP --> EmailServer
|
||||
DNS --> DnsServer
|
||||
RADIUS --> RadiusServer
|
||||
|
||||
DcRouter --> SmartProxy
|
||||
DcRouter --> EmailServer
|
||||
DcRouter --> DnsServer
|
||||
DcRouter --> RadiusServer
|
||||
DcRouter --> CertManager
|
||||
DcRouter --> OpsServer
|
||||
|
||||
SmartProxy --> WebServices
|
||||
SmartProxy --> APIs
|
||||
@@ -192,12 +246,30 @@ High-performance HTTP/HTTPS and TCP/SNI proxy with:
|
||||
Enterprise-grade SMTP server with:
|
||||
- Multi-domain support
|
||||
- Pattern-based routing
|
||||
- Three processing modes
|
||||
- Complete authentication stack
|
||||
- Four processing modes (forward, process, deliver, reject)
|
||||
- Complete authentication stack (DKIM, SPF, DMARC)
|
||||
|
||||
#### **DNS Server**
|
||||
Authoritative DNS server with:
|
||||
- Dynamic record management
|
||||
- DNS-over-HTTPS (DoH) support
|
||||
- ACME DNS-01 challenge handling
|
||||
|
||||
#### **RADIUS Server**
|
||||
Network authentication server with:
|
||||
- MAC Authentication Bypass (MAB)
|
||||
- VLAN assignment
|
||||
- Accounting support
|
||||
|
||||
#### **Certificate Manager**
|
||||
Automatic TLS certificate provisioning via ACME with DNS-01 challenges.
|
||||
|
||||
#### **OpsServer Dashboard**
|
||||
Web-based management interface with:
|
||||
- JWT-secured API
|
||||
- Real-time statistics
|
||||
- Configuration management
|
||||
|
||||
## Configuration
|
||||
|
||||
### Complete Configuration Interface
|
||||
@@ -239,6 +311,27 @@ interface IDcRouterOptions {
|
||||
// DNS domain for automatic DNS-over-HTTPS setup
|
||||
dnsDomain?: string; // e.g., 'dns.example.com'
|
||||
|
||||
// DNS nameserver domains (enables authoritative DNS)
|
||||
dnsNsDomains?: string[]; // e.g., ['ns1.example.com', 'ns2.example.com']
|
||||
|
||||
// RADIUS server configuration
|
||||
radiusConfig?: {
|
||||
port?: number;
|
||||
secret: string;
|
||||
clients?: IRadiusClient[];
|
||||
macAuth?: IMacAuthConfig;
|
||||
vlanAssignment?: IVlanAssignment[];
|
||||
};
|
||||
|
||||
// OpsServer configuration
|
||||
opsServerConfig?: {
|
||||
port?: number;
|
||||
admin: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
|
||||
// TLS and certificate configuration
|
||||
tls?: {
|
||||
contactEmail: string;
|
||||
@@ -271,10 +364,10 @@ interface IRouteConfig {
|
||||
};
|
||||
action: {
|
||||
type: 'forward' | 'redirect' | 'serve';
|
||||
target?: {
|
||||
targets?: Array<{
|
||||
host: string;
|
||||
port: number | 'preserve' | ((context: any) => number);
|
||||
};
|
||||
}>;
|
||||
tls?: {
|
||||
mode: 'terminate' | 'passthrough';
|
||||
certificate?: 'auto' | string;
|
||||
@@ -548,30 +641,6 @@ Different handling for authenticated vs unauthenticated senders:
|
||||
}
|
||||
```
|
||||
|
||||
#### **Content-Based Filtering**
|
||||
Filter based on size, subject, or headers:
|
||||
```typescript
|
||||
{
|
||||
name: 'large-email-reject',
|
||||
match: { sizeRange: { min: 25000000 } }, // > 25MB
|
||||
action: {
|
||||
type: 'reject',
|
||||
reject: { code: 552, message: 'Message too large' }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'priority-emails',
|
||||
match: {
|
||||
headers: { 'X-Priority': 'high' },
|
||||
subject: /urgent|emergency/i
|
||||
},
|
||||
action: {
|
||||
type: 'process',
|
||||
process: { queue: 'priority' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Email Security Features
|
||||
|
||||
#### **Route Matching Patterns**
|
||||
@@ -640,15 +709,7 @@ const routes = [
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: '192.168.1.20',
|
||||
port: (context) => {
|
||||
// Route based on path
|
||||
if (context.path.startsWith('/v1/')) return 8080;
|
||||
if (context.path.startsWith('/v2/')) return 8081;
|
||||
return 8080;
|
||||
}
|
||||
},
|
||||
targets: [{ host: '192.168.1.20', port: 8080 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
@@ -687,10 +748,7 @@ const tcpRoutes = [
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: '192.168.1.30',
|
||||
port: 'preserve'
|
||||
},
|
||||
targets: [{ host: '192.168.1.30', port: 'preserve' }],
|
||||
security: {
|
||||
ipAllowList: ['192.168.1.0/24']
|
||||
}
|
||||
@@ -706,10 +764,7 @@ const tcpRoutes = [
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: '192.168.1.40',
|
||||
port: 8443
|
||||
},
|
||||
targets: [{ host: '192.168.1.40', port: 8443 }],
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
@@ -718,6 +773,72 @@ const tcpRoutes = [
|
||||
];
|
||||
```
|
||||
|
||||
## RADIUS Server
|
||||
|
||||
DcRouter includes a RADIUS server for network access control:
|
||||
|
||||
### Basic RADIUS Configuration
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
radiusConfig: {
|
||||
port: 1812,
|
||||
secret: 'your-radius-secret',
|
||||
clients: [
|
||||
{
|
||||
name: 'switch-1',
|
||||
ip: '192.168.1.1',
|
||||
secret: 'client-secret'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### MAC Authentication Bypass (MAB)
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
radiusConfig: {
|
||||
port: 1812,
|
||||
secret: 'radius-secret',
|
||||
macAuth: {
|
||||
enabled: true,
|
||||
allowedMacs: [
|
||||
'aa:bb:cc:dd:ee:ff',
|
||||
'aa:bb:cc:*' // Wildcard for OUI matching
|
||||
],
|
||||
defaultVlan: 100,
|
||||
guestVlan: 999
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### VLAN Assignment
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
radiusConfig: {
|
||||
secret: 'radius-secret',
|
||||
vlanAssignment: [
|
||||
{
|
||||
match: { mac: 'aa:bb:cc:*' }, // Vendor OUI match
|
||||
vlan: 100
|
||||
},
|
||||
{
|
||||
match: { mac: 'dd:ee:ff:*' },
|
||||
vlan: 200
|
||||
},
|
||||
{
|
||||
match: { default: true },
|
||||
vlan: 999 // Guest VLAN
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Storage System
|
||||
|
||||
### StorageManager
|
||||
@@ -765,31 +886,6 @@ The storage system is used for:
|
||||
- **IP Reputation**: `/security/ip-reputation/{ip}.json`
|
||||
- **Domain Configs**: `/email/domains/{domain}.json`
|
||||
|
||||
### Data Migration
|
||||
|
||||
Migrate data between storage backends:
|
||||
|
||||
```typescript
|
||||
import { StorageManager } from '@serve.zone/dcrouter';
|
||||
|
||||
// Export from filesystem
|
||||
const fsStorage = new StorageManager({ fsPath: './data' });
|
||||
const keys = await fsStorage.list('/');
|
||||
const data = {};
|
||||
for (const key of keys) {
|
||||
data[key] = await fsStorage.get(key);
|
||||
}
|
||||
|
||||
// Import to cloud storage
|
||||
const cloudStorage = new StorageManager({
|
||||
readFunction: cloudRead,
|
||||
writeFunction: cloudWrite
|
||||
});
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
await cloudStorage.set(key, value);
|
||||
}
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### IP Reputation Checking
|
||||
@@ -810,21 +906,57 @@ if (result.isBlocked) {
|
||||
}
|
||||
```
|
||||
|
||||
### Content Security Scanner
|
||||
### Rate Limiting
|
||||
|
||||
```typescript
|
||||
import { ContentScanner } from '@serve.zone/dcrouter';
|
||||
|
||||
const scanner = new ContentScanner({
|
||||
spamThreshold: 5.0,
|
||||
virusScanning: true,
|
||||
attachmentFiltering: {
|
||||
maxSize: 25 * 1024 * 1024,
|
||||
blockedTypes: ['.exe', '.bat', '.scr']
|
||||
const router = new DcRouter({
|
||||
emailConfig: {
|
||||
rateLimits: {
|
||||
inbound: {
|
||||
messagesPerMinute: 100,
|
||||
connectionsPerIp: 10,
|
||||
recipientsPerMessage: 50
|
||||
},
|
||||
outbound: {
|
||||
messagesPerHour: 1000,
|
||||
messagesPerDay: 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
const scanResult = await scanner.scanEmail(email);
|
||||
## OpsServer Dashboard
|
||||
|
||||
The OpsServer provides a web-based management interface:
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time Statistics**: View connections, email throughput, DNS queries, RADIUS sessions
|
||||
- **Configuration Display**: View current configuration (read-only)
|
||||
- **Log Viewer**: Access system logs with filtering
|
||||
- **Security Dashboard**: Monitor threats and blocked connections
|
||||
|
||||
### API Endpoints
|
||||
|
||||
The OpsServer exposes TypedRequest endpoints:
|
||||
|
||||
```typescript
|
||||
// Health check
|
||||
POST /typedrequest { method: 'getHealthStatus' }
|
||||
|
||||
// Server statistics
|
||||
POST /typedrequest { method: 'getServerStatistics' }
|
||||
|
||||
// Configuration (read-only)
|
||||
POST /typedrequest { method: 'getConfiguration' }
|
||||
|
||||
// Logs
|
||||
POST /typedrequest { method: 'getLogs', data: { level: 'info', limit: 100 } }
|
||||
|
||||
// RADIUS
|
||||
POST /typedrequest { method: 'getRadiusSessions' }
|
||||
POST /typedrequest { method: 'getRadiusClients' }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
@@ -839,7 +971,7 @@ constructor(options: IDcRouterOptions)
|
||||
#### Methods
|
||||
|
||||
##### `start(): Promise<void>`
|
||||
Starts all configured services (SmartProxy, email server, DNS server).
|
||||
Starts all configured services (SmartProxy, email server, DNS server, RADIUS server, OpsServer).
|
||||
|
||||
##### `stop(): Promise<void>`
|
||||
Gracefully stops all services.
|
||||
@@ -872,9 +1004,6 @@ const status = router.emailService.getEmailStatus(emailId);
|
||||
console.log(status.status); // 'pending', 'sent', 'delivered', 'bounced'
|
||||
```
|
||||
|
||||
#### `getDeliveryReport(emailId: string): IDeliveryReport`
|
||||
Detailed delivery information including bounce reasons and tracking data.
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Enterprise Setup
|
||||
@@ -883,6 +1012,15 @@ Detailed delivery information including bounce reasons and tracking data.
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
// OpsServer dashboard
|
||||
opsServerConfig: {
|
||||
port: 3000,
|
||||
admin: {
|
||||
username: 'admin',
|
||||
password: process.env.ADMIN_PASSWORD
|
||||
}
|
||||
},
|
||||
|
||||
// HTTP/HTTPS routing
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
@@ -893,7 +1031,7 @@ const router = new DcRouter({
|
||||
match: { domains: ['example.com', 'www.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: '192.168.1.10', port: 80 },
|
||||
targets: [{ host: '192.168.1.10', port: 80 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
},
|
||||
@@ -905,20 +1043,9 @@ const router = new DcRouter({
|
||||
match: { domains: ['api.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: '192.168.1.20', port: 8080 },
|
||||
targets: [{ host: '192.168.1.20', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
},
|
||||
|
||||
// Internal services
|
||||
{
|
||||
name: 'internal',
|
||||
match: { ports: [{ from: 8000, to: 8999 }] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: '192.168.1.30', port: 'preserve' },
|
||||
security: { ipAllowList: ['192.168.0.0/16'] }
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -945,28 +1072,9 @@ const router = new DcRouter({
|
||||
selector: 'mail',
|
||||
rotateKeys: true
|
||||
}
|
||||
},
|
||||
{
|
||||
domain: 'notifications.example.com',
|
||||
dnsMode: 'internal-dns',
|
||||
rateLimits: {
|
||||
outbound: { messagesPerHour: 10000 }
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
// Authentication configuration
|
||||
auth: {
|
||||
required: true,
|
||||
methods: ['PLAIN', 'LOGIN']
|
||||
},
|
||||
|
||||
// TLS configuration
|
||||
tls: {
|
||||
keyPath: './certs/mail-key.pem',
|
||||
certPath: './certs/mail-cert.pem'
|
||||
},
|
||||
|
||||
// Email routing rules
|
||||
routes: [
|
||||
// Relay from office network
|
||||
@@ -976,39 +1084,18 @@ const router = new DcRouter({
|
||||
match: { clientIp: '192.168.0.0/16' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: 'internal-mail.example.com',
|
||||
port: 25
|
||||
}
|
||||
forward: { host: 'internal-mail.example.com', port: 25 }
|
||||
}
|
||||
},
|
||||
|
||||
// Transactional emails via processing
|
||||
// Process transactional emails
|
||||
{
|
||||
name: 'notifications',
|
||||
priority: 50,
|
||||
match: { recipients: '*@notifications.example.com' },
|
||||
action: {
|
||||
type: 'process',
|
||||
process: {
|
||||
scan: true,
|
||||
dkim: true,
|
||||
queue: 'priority'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Internal emails forwarded to Exchange
|
||||
{
|
||||
name: 'internal-mail',
|
||||
priority: 25,
|
||||
match: { recipients: '*@example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: 'exchange.internal.example.com',
|
||||
port: 25
|
||||
}
|
||||
process: { scan: true, dkim: true, queue: 'priority' }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1018,15 +1105,23 @@ const router = new DcRouter({
|
||||
match: { recipients: '*' },
|
||||
action: {
|
||||
type: 'reject',
|
||||
reject: {
|
||||
code: 550,
|
||||
message: 'Relay denied'
|
||||
}
|
||||
reject: { code: 550, message: 'Relay denied' }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// RADIUS for network devices
|
||||
radiusConfig: {
|
||||
port: 1812,
|
||||
secret: process.env.RADIUS_SECRET,
|
||||
macAuth: {
|
||||
enabled: true,
|
||||
defaultVlan: 100,
|
||||
guestVlan: 999
|
||||
}
|
||||
},
|
||||
|
||||
// DNS server for ACME challenges
|
||||
dnsServerConfig: {
|
||||
port: 53,
|
||||
@@ -1056,33 +1151,33 @@ setInterval(() => {
|
||||
}, 60000);
|
||||
```
|
||||
|
||||
### Email Template System
|
||||
## Testing
|
||||
|
||||
```typescript
|
||||
import { EmailService, TemplateManager } from '@serve.zone/dcrouter';
|
||||
### Comprehensive Test Suite
|
||||
|
||||
// Setup email templates
|
||||
const templateManager = new TemplateManager();
|
||||
templateManager.addTemplate('welcome', {
|
||||
subject: 'Welcome to {{company}}!',
|
||||
html: `
|
||||
<h1>Welcome {{name}}!</h1>
|
||||
<p>Thank you for joining {{company}}.</p>
|
||||
<p>Your account: {{email}}</p>
|
||||
`,
|
||||
text: 'Welcome {{name}}! Thank you for joining {{company}}.'
|
||||
});
|
||||
DcRouter includes a comprehensive test suite with 195 test files covering all aspects of the system:
|
||||
|
||||
// Send templated email
|
||||
const emailService = new EmailService(router);
|
||||
await emailService.sendTemplatedEmail('welcome', {
|
||||
to: 'user@example.com',
|
||||
templateData: {
|
||||
name: 'John Doe',
|
||||
company: 'Example Corp',
|
||||
email: 'user@example.com'
|
||||
}
|
||||
});
|
||||
#### SMTP Protocol Tests
|
||||
- **Commands**: EHLO, HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT, VRFY, EXPN, HELP
|
||||
- **Extensions**: SIZE, PIPELINING, STARTTLS
|
||||
- **Connection Management**: TLS/plain connections, timeouts, limits, rejection handling
|
||||
- **Error Handling**: Syntax errors, invalid sequences, temporary/permanent failures
|
||||
- **Email Processing**: Basic sending, multiple recipients, large emails, invalid addresses
|
||||
- **Security**: Authentication, rate limiting
|
||||
- **Performance**: Throughput testing
|
||||
- **Edge Cases**: Very large emails, special characters
|
||||
|
||||
#### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run specific test categories
|
||||
tsx test/suite/smtpserver_commands/test.cmd-01.ehlo-command.ts
|
||||
|
||||
# Run with verbose output
|
||||
tstest test/test.integration.ts --verbose
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -1118,25 +1213,6 @@ dig TXT your-domain.com
|
||||
- Test CIDR notation: `192.168.0.0/16` includes all 192.168.x.x addresses
|
||||
- Confirm authentication state matches your expectations
|
||||
|
||||
**Common Route Patterns**
|
||||
```typescript
|
||||
// Debug route to log all traffic
|
||||
{
|
||||
name: 'debug-all',
|
||||
priority: 1000,
|
||||
match: { recipients: '*' },
|
||||
action: { type: 'process', process: { scan: false } }
|
||||
}
|
||||
|
||||
// Catch-all reject (should be lowest priority)
|
||||
{
|
||||
name: 'default-reject',
|
||||
priority: 0,
|
||||
match: { recipients: '*' },
|
||||
action: { type: 'reject', reject: { code: 550, message: 'No route' } }
|
||||
}
|
||||
```
|
||||
|
||||
#### DNS Issues
|
||||
```bash
|
||||
# Test DNS server
|
||||
@@ -1146,32 +1222,6 @@ dig @your-server.com your-domain.com
|
||||
dig your-domain.com @8.8.8.8
|
||||
```
|
||||
|
||||
### Logging and Monitoring
|
||||
|
||||
```typescript
|
||||
import { SmartLog } from '@push.rocks/smartlog';
|
||||
|
||||
// Configure logging
|
||||
const logger = new SmartLog({
|
||||
level: 'info',
|
||||
transport: 'console'
|
||||
});
|
||||
|
||||
// Monitor email events
|
||||
router.emailServer.on('emailReceived', (email) => {
|
||||
logger.log('info', `Email received: ${email.from} -> ${email.to}`);
|
||||
});
|
||||
|
||||
router.emailServer.on('emailSent', (result) => {
|
||||
logger.log('info', `Email sent: ${result.messageId} (${result.status})`);
|
||||
});
|
||||
|
||||
// Monitor proxy events
|
||||
router.smartProxy.on('connectionEstablished', (connection) => {
|
||||
logger.log('info', `Connection: ${connection.clientIp} -> ${connection.target}`);
|
||||
});
|
||||
```
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
```typescript
|
||||
@@ -1196,60 +1246,23 @@ const performanceConfig = {
|
||||
};
|
||||
```
|
||||
|
||||
## License
|
||||
## License and Legal Information
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
## Testing
|
||||
**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.
|
||||
|
||||
### Comprehensive Test Suite
|
||||
### Trademarks
|
||||
|
||||
DcRouter includes a comprehensive test suite covering all aspects of the system:
|
||||
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.
|
||||
|
||||
#### SMTP Protocol Tests
|
||||
- **Commands**: EHLO, HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT, VRFY, EXPN, HELP
|
||||
- **Extensions**: SIZE, PIPELINING, STARTTLS
|
||||
- **Connection Management**: TLS/plain connections, timeouts, limits, rejection handling
|
||||
- **Error Handling**: Syntax errors, invalid sequences, temporary/permanent failures
|
||||
- **Email Processing**: Basic sending, multiple recipients, large emails, invalid addresses
|
||||
- **Security**: Authentication, rate limiting
|
||||
- **Performance**: Throughput testing
|
||||
- **Edge Cases**: Very large emails, special characters
|
||||
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.
|
||||
|
||||
#### Storage and Configuration Tests
|
||||
- **Storage Manager**: All backend types (filesystem, custom, memory)
|
||||
- **Integration**: Component storage usage and persistence
|
||||
- **DNS Validation**: All DNS modes (forward, internal, external)
|
||||
- **DNS Mode Switching**: Dynamic configuration changes
|
||||
- **Data Migration**: Moving data between storage backends
|
||||
### Company Information
|
||||
|
||||
#### Running Tests
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
# Run specific test categories
|
||||
tsx test/suite/commands/test.ehlo-command.ts
|
||||
tsx test/suite/connection/test.tls-connection.ts
|
||||
tsx test/suite/email-processing/test.basic-email.ts
|
||||
|
||||
# Run with verbose output
|
||||
tstest test/suite/security/test.authentication.ts --verbose
|
||||
```
|
||||
|
||||
### Test Infrastructure
|
||||
|
||||
The test suite uses a self-contained pattern where each test:
|
||||
1. Starts its own SMTP server instance
|
||||
2. Runs comprehensive test scenarios
|
||||
3. Cleans up all resources
|
||||
4. Provides detailed logging for debugging
|
||||
|
||||
This ensures tests are isolated, reliable, and can run in parallel.
|
||||
|
||||
## Support
|
||||
|
||||
- Documentation: [https://docs.serve.zone/dcrouter](https://docs.serve.zone/dcrouter)
|
||||
- Issues: [https://github.com/serve-zone/dcrouter/issues](https://github.com/serve-zone/dcrouter/issues)
|
||||
- Community: [https://community.serve.zone](https://community.serve.zone)
|
||||
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.
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
# Metrics Implementation Plan with @push.rocks/smartmetrics
|
||||
|
||||
Command to reread CLAUDE.md: `cat /home/centraluser/eu.central.ingress-2/CLAUDE.md`
|
||||
|
||||
## Overview
|
||||
This plan outlines the migration from placeholder/demo metrics to real metrics using @push.rocks/smartmetrics for the dcrouter project.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Currently Implemented (Real Data)
|
||||
- CPU usage (basic calculation from os.loadavg)
|
||||
- Memory usage (from process.memoryUsage)
|
||||
- System uptime
|
||||
|
||||
### Currently Stubbed (Returns 0 or Demo Data)
|
||||
- Active connections (HTTP/HTTPS/WebSocket)
|
||||
- Total connections
|
||||
- Requests per second
|
||||
- Email statistics (sent/received/failed/queued/bounce rate)
|
||||
- DNS statistics (queries/cache hits/response times)
|
||||
- Security metrics (blocked IPs/auth failures/spam detection)
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Infrastructure Setup
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
pnpm install --save @push.rocks/smartmetrics
|
||||
```
|
||||
|
||||
2. **Update plugins.ts**
|
||||
- Add smartmetrics to ts/plugins.ts
|
||||
- Import as: `import * as smartmetrics from '@push.rocks/smartmetrics';`
|
||||
|
||||
3. **Create Metrics Manager Class**
|
||||
- Location: `ts/monitoring/classes.metricsmanager.ts`
|
||||
- Initialize SmartMetrics with existing logger
|
||||
- Configure for dcrouter service identification
|
||||
- Set up automatic metric collection intervals
|
||||
|
||||
### Phase 2: Connection Tracking Implementation
|
||||
|
||||
1. **HTTP/HTTPS Connection Tracking**
|
||||
- Instrument the SmartProxy connection handlers
|
||||
- Track active connections in real-time
|
||||
- Monitor connection lifecycle (open/close events)
|
||||
- Location: Update connection managers in routing system
|
||||
|
||||
2. **Email Connection Tracking**
|
||||
- Instrument SMTP server connection handlers
|
||||
- Track both incoming and outgoing connections
|
||||
- Location: `ts/mail/delivery/smtpserver/connection-manager.ts`
|
||||
|
||||
3. **DNS Query Tracking**
|
||||
- Instrument DNS server handlers
|
||||
- Track query counts and response times
|
||||
- Location: `ts/mail/routing/classes.dns.manager.ts`
|
||||
|
||||
### Phase 3: Email Metrics Collection
|
||||
|
||||
1. **Email Processing Metrics**
|
||||
- Track sent/received/failed emails
|
||||
- Monitor queue sizes
|
||||
- Calculate delivery and bounce rates
|
||||
- Location: Instrument `classes.delivery.queue.ts` and `classes.emailsendjob.ts`
|
||||
|
||||
2. **Email Performance Metrics**
|
||||
- Track processing times
|
||||
- Monitor queue throughput
|
||||
- Location: Update delivery system classes
|
||||
|
||||
### Phase 4: Security Metrics Integration
|
||||
|
||||
1. **Security Event Tracking**
|
||||
- Track blocked IPs from IPReputationChecker
|
||||
- Monitor authentication failures
|
||||
- Count spam/malware/phishing detections
|
||||
- Location: Instrument security classes in `ts/security/`
|
||||
|
||||
### Phase 5: Stats Handler Refactoring
|
||||
|
||||
1. **Update Stats Handler**
|
||||
- Location: `ts/opsserver/handlers/stats.handler.ts`
|
||||
- Replace all stub implementations with MetricsManager calls
|
||||
- Maintain existing API interface structure
|
||||
|
||||
2. **Metrics Aggregation**
|
||||
- Implement proper time-window aggregations
|
||||
- Add historical data storage (last hour/day)
|
||||
- Calculate rates and percentages accurately
|
||||
|
||||
### Phase 6: Prometheus Integration (Optional Enhancement)
|
||||
|
||||
1. **Enable Prometheus Endpoint**
|
||||
- Add Prometheus metrics endpoint
|
||||
- Configure port (default: 9090)
|
||||
- Document metrics for monitoring systems
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### MetricsManager Core Structure
|
||||
```typescript
|
||||
export class MetricsManager {
|
||||
private smartMetrics: smartmetrics.SmartMetrics;
|
||||
private connectionTrackers: Map<string, ConnectionTracker>;
|
||||
private emailMetrics: EmailMetricsCollector;
|
||||
private dnsMetrics: DnsMetricsCollector;
|
||||
private securityMetrics: SecurityMetricsCollector;
|
||||
|
||||
// Real-time counters
|
||||
private activeConnections = {
|
||||
http: 0,
|
||||
https: 0,
|
||||
websocket: 0,
|
||||
smtp: 0
|
||||
};
|
||||
|
||||
// Initialize and start collection
|
||||
public async start(): Promise<void>;
|
||||
|
||||
// Get aggregated metrics for stats handler
|
||||
public async getServerStats(): Promise<IServerStats>;
|
||||
public async getEmailStats(): Promise<IEmailStats>;
|
||||
public async getDnsStats(): Promise<IDnsStats>;
|
||||
public async getSecurityStats(): Promise<ISecurityStats>;
|
||||
}
|
||||
```
|
||||
|
||||
### Connection Tracking Pattern
|
||||
```typescript
|
||||
// Example for HTTP connections
|
||||
onConnectionOpen(type: string) {
|
||||
this.activeConnections[type]++;
|
||||
this.totalConnections[type]++;
|
||||
}
|
||||
|
||||
onConnectionClose(type: string) {
|
||||
this.activeConnections[type]--;
|
||||
}
|
||||
```
|
||||
|
||||
### Email Metrics Pattern
|
||||
```typescript
|
||||
// Track email events
|
||||
onEmailSent() { this.emailsSentToday++; }
|
||||
onEmailReceived() { this.emailsReceivedToday++; }
|
||||
onEmailFailed() { this.emailsFailedToday++; }
|
||||
onEmailQueued() { this.queueSize++; }
|
||||
onEmailDequeued() { this.queueSize--; }
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**
|
||||
- Test MetricsManager initialization
|
||||
- Test metric collection accuracy
|
||||
- Test aggregation calculations
|
||||
|
||||
2. **Integration Tests**
|
||||
- Test metrics flow from source to API
|
||||
- Verify real-time updates
|
||||
- Test under load conditions
|
||||
|
||||
3. **Debug Utilities**
|
||||
- Create `.nogit/debug/test-metrics.ts` for quick testing
|
||||
- Add metrics dump endpoint for debugging
|
||||
|
||||
## Migration Steps
|
||||
|
||||
1. Implement MetricsManager without breaking existing code
|
||||
2. Wire up one metric type at a time
|
||||
3. Verify each metric shows real data
|
||||
4. Remove TODO comments from stats handler
|
||||
5. Update tests to expect real values
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All metrics show real, accurate data
|
||||
- [ ] No performance degradation
|
||||
- [ ] Metrics update in real-time
|
||||
- [ ] Historical data is collected
|
||||
- [ ] All TODO comments removed from stats handler
|
||||
- [ ] Tests pass with real metric values
|
||||
|
||||
## Notes
|
||||
|
||||
- SmartMetrics provides CPU and memory metrics out of the box
|
||||
- We'll need custom collectors for application-specific metrics
|
||||
- Consider adding metric persistence for historical data
|
||||
- Prometheus integration provides industry-standard monitoring
|
||||
|
||||
## Questions to Address
|
||||
|
||||
1. Should we persist metrics to disk for historical analysis?
|
||||
2. What time windows should we support (5min, 1hour, 1day)?
|
||||
3. Should we add alerting thresholds?
|
||||
4. Do we need custom metric types beyond the current interface?
|
||||
|
||||
---
|
||||
|
||||
This plan ensures a systematic migration from demo metrics to real, actionable data using @push.rocks/smartmetrics while maintaining the existing API structure and adding powerful monitoring capabilities.
|
||||
@@ -1,173 +0,0 @@
|
||||
# Module Adjustments for Metrics Collection
|
||||
|
||||
Command to reread CLAUDE.md: `cat /home/centraluser/eu.central.ingress-2/CLAUDE.md`
|
||||
|
||||
## SmartProxy Adjustments
|
||||
|
||||
### Current State
|
||||
SmartProxy (@push.rocks/smartproxy) provides:
|
||||
- Route-level `maxConnections` limiting
|
||||
- Event emission system (currently only for certificates)
|
||||
- NFTables integration with packet statistics
|
||||
- Connection monitoring during active sessions
|
||||
|
||||
### Missing Capabilities for Metrics
|
||||
1. **No Connection Lifecycle Events**
|
||||
- No `connection-open` or `connection-close` events
|
||||
- No way to track active connections in real-time
|
||||
- No exposure of internal connection tracking
|
||||
|
||||
2. **No Statistics API**
|
||||
- No methods like `getActiveConnections()` or `getConnectionStats()`
|
||||
- No access to connection counts per route
|
||||
- No throughput or performance metrics exposed
|
||||
|
||||
3. **Limited Event System**
|
||||
- Currently only emits certificate-related events
|
||||
- No connection, request, or performance events
|
||||
|
||||
### Required Adjustments
|
||||
1. **Add Connection Tracking Events**
|
||||
```typescript
|
||||
// Emit on new connection
|
||||
smartProxy.emit('connection-open', {
|
||||
type: 'http' | 'https' | 'websocket',
|
||||
routeName: string,
|
||||
clientIp: string,
|
||||
timestamp: Date
|
||||
});
|
||||
|
||||
// Emit on connection close
|
||||
smartProxy.emit('connection-close', {
|
||||
connectionId: string,
|
||||
duration: number,
|
||||
bytesTransferred: number
|
||||
});
|
||||
```
|
||||
|
||||
2. **Add Statistics API**
|
||||
```typescript
|
||||
interface IProxyStats {
|
||||
getActiveConnections(): number;
|
||||
getConnectionsByRoute(): Map<string, number>;
|
||||
getTotalConnections(): number;
|
||||
getRequestsPerSecond(): number;
|
||||
getThroughput(): { bytesIn: number, bytesOut: number };
|
||||
}
|
||||
```
|
||||
|
||||
3. **Expose Internal Metrics**
|
||||
- Make connection pools accessible
|
||||
- Expose route-level statistics
|
||||
- Provide request/response metrics
|
||||
|
||||
### Alternative Approach
|
||||
Since SmartProxy is already used with socket handlers for email routing, we could:
|
||||
1. Wrap all SmartProxy socket handlers with a metrics-aware wrapper
|
||||
2. Use the existing socket-handler pattern to intercept all connections
|
||||
3. Track connections at the dcrouter level rather than modifying SmartProxy
|
||||
|
||||
## SmartDNS Adjustments
|
||||
|
||||
### Current State
|
||||
SmartDNS (@push.rocks/smartdns) provides:
|
||||
- DNS query handling via registered handlers
|
||||
- Support for UDP (port 53) and DNS-over-HTTPS
|
||||
- Domain pattern matching and routing
|
||||
- DNSSEC support
|
||||
|
||||
### Missing Capabilities for Metrics
|
||||
1. **No Query Tracking**
|
||||
- No counters for total queries
|
||||
- No breakdown by query type (A, AAAA, MX, etc.)
|
||||
- No domain popularity tracking
|
||||
|
||||
2. **No Performance Metrics**
|
||||
- No response time tracking
|
||||
- No cache hit/miss statistics
|
||||
- No error rate tracking
|
||||
|
||||
3. **No Event Emission**
|
||||
- No query lifecycle events
|
||||
- No cache events
|
||||
- No error events
|
||||
|
||||
### Required Adjustments
|
||||
1. **Add Query Interceptor/Middleware**
|
||||
```typescript
|
||||
// Wrap handler registration to add metrics
|
||||
smartDns.use((query, next) => {
|
||||
metricsCollector.trackQuery(query);
|
||||
const startTime = Date.now();
|
||||
|
||||
next((response) => {
|
||||
metricsCollector.trackResponse(response, Date.now() - startTime);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
2. **Add Event Emissions**
|
||||
```typescript
|
||||
// Query events
|
||||
smartDns.emit('query-received', {
|
||||
type: query.type,
|
||||
domain: query.domain,
|
||||
source: 'udp' | 'https',
|
||||
clientIp: string
|
||||
});
|
||||
|
||||
smartDns.emit('query-answered', {
|
||||
cached: boolean,
|
||||
responseTime: number,
|
||||
responseCode: string
|
||||
});
|
||||
```
|
||||
|
||||
3. **Add Statistics API**
|
||||
```typescript
|
||||
interface IDnsStats {
|
||||
getTotalQueries(): number;
|
||||
getQueriesPerSecond(): number;
|
||||
getCacheStats(): { hits: number, misses: number, hitRate: number };
|
||||
getTopDomains(limit: number): Array<{ domain: string, count: number }>;
|
||||
getQueryTypeBreakdown(): Record<string, number>;
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative Approach
|
||||
Since we control the handler registration in dcrouter:
|
||||
1. Create a metrics-aware handler wrapper at the dcrouter level
|
||||
2. Wrap all DNS handlers before registration
|
||||
3. Track metrics without modifying SmartDNS itself
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Option 1: Fork and Modify Dependencies
|
||||
- Fork @push.rocks/smartproxy and @push.rocks/smartdns
|
||||
- Add metrics capabilities directly
|
||||
- Maintain custom versions
|
||||
- **Pros**: Clean integration, full control
|
||||
- **Cons**: Maintenance burden, divergence from upstream
|
||||
|
||||
### Option 2: Wrapper Approach at DcRouter Level
|
||||
- Create wrapper classes that intercept all operations
|
||||
- Track metrics at the application level
|
||||
- No modifications to dependencies
|
||||
- **Pros**: No dependency modifications, easier to maintain
|
||||
- **Cons**: May miss some internal events, slightly higher overhead
|
||||
|
||||
### Option 3: Contribute Back to Upstream
|
||||
- Submit PRs to add metrics capabilities to original packages
|
||||
- Work with maintainers to add event emissions and stats APIs
|
||||
- **Pros**: Benefits everyone, no fork maintenance
|
||||
- **Cons**: Slower process, may not align with maintainer vision
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Use Option 2 (Wrapper Approach)** for immediate implementation:
|
||||
1. Create `MetricsAwareProxy` and `MetricsAwareDns` wrapper classes
|
||||
2. Intercept all operations and track metrics
|
||||
3. Minimal changes to existing codebase
|
||||
4. Can migrate to Option 3 later if upstream accepts contributions
|
||||
|
||||
This approach allows us to implement comprehensive metrics collection without modifying external dependencies, maintaining compatibility and reducing maintenance burden.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Network Metrics Integration Status
|
||||
|
||||
## Command: `pnpm run build && curl https://code.foss.global/push.rocks/smartproxy/raw/branch/master/readme.md`
|
||||
|
||||
## Completed Tasks (2025-06-23)
|
||||
|
||||
### ✅ SmartProxy Metrics API Integration
|
||||
- Updated MetricsManager to use new SmartProxy v19.6.7 metrics API
|
||||
- Replaced deprecated `getStats()` with `getMetrics()` and `getStatistics()`
|
||||
- Fixed method calls to use grouped API structure:
|
||||
- `metrics.connections.active()` for active connections
|
||||
- `metrics.throughput.instant()` for real-time throughput
|
||||
- `metrics.connections.topIPs()` for top connected IPs
|
||||
|
||||
### ✅ Removed Mock Data
|
||||
- Removed hardcoded `0.0.0.0` IPs in security.handler.ts
|
||||
- Removed `Math.random()` trend data in ops-view-network.ts
|
||||
- Now using real IP data from SmartProxy metrics
|
||||
|
||||
### ✅ Enhanced Metrics Functionality
|
||||
- Email metrics: delivery time tracking, top recipients, activity log
|
||||
- DNS metrics: query rate calculations, response time tracking
|
||||
- Security metrics: incident logging with severity levels
|
||||
|
||||
### ✅ Fixed Network Traffic Display
|
||||
- All throughput now shown in bits per second (kbit/s, Mbit/s, Gbit/s)
|
||||
- Network graph shows separate lines for inbound (green) and outbound (purple)
|
||||
- Fixed throughput calculation to use same data source as tiles
|
||||
- Added tooltips showing both timestamp and value
|
||||
|
||||
### ✅ Fixed Requests/sec Tile
|
||||
- Shows actual request counts (derived from connections)
|
||||
- Trend line now shows request history, not throughput
|
||||
- Consistent data between number and trend visualization
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Data Flow
|
||||
1. SmartProxy collects metrics via its internal MetricsCollector
|
||||
2. MetricsManager retrieves data using `smartProxy.getMetrics()`
|
||||
3. Handlers transform metrics for UI consumption
|
||||
4. UI components display real-time data with auto-refresh
|
||||
|
||||
### Key Components
|
||||
- **MetricsManager**: Central metrics aggregation and tracking
|
||||
- **SmartProxy Integration**: Uses grouped metrics API
|
||||
- **UI Components**: ops-view-network shows real-time traffic graphs
|
||||
- **State Management**: Uses appstate for reactive updates
|
||||
|
||||
## Known Limitations
|
||||
- Request counting is derived from connection data (not true HTTP request counts)
|
||||
- Some metrics still need backend implementation (e.g., per-connection bytes)
|
||||
- Historical data limited to current session
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
# Build and run
|
||||
pnpm build
|
||||
pnpm start
|
||||
|
||||
# Check metrics endpoints
|
||||
curl http://localhost:4000/api/stats/server
|
||||
curl http://localhost:4000/api/stats/network
|
||||
```
|
||||
|
||||
## Success Metrics
|
||||
- [x] Real-time throughput data displayed correctly
|
||||
- [x] No mock data in production UI
|
||||
- [x] Consistent units across all displays
|
||||
- [x] Separate in/out traffic visualization
|
||||
- [x] Working trend lines in stat tiles
|
||||
@@ -1,46 +0,0 @@
|
||||
# Plan: Implement dees-statsgrid in DCRouter UI
|
||||
|
||||
Command to reread CLAUDE.md: `Read /home/centraluser/eu.central.ingress-2/CLAUDE.md`
|
||||
|
||||
## Overview
|
||||
Replace the current stats cards with the new dees-statsgrid component from @design.estate/dees-catalog for better visualization and consistency.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Update Overview View (`ops-view-overview.ts`)
|
||||
- Replace the custom stats cards with dees-statsgrid
|
||||
- Use appropriate tile types for different metrics:
|
||||
- `gauge` for CPU and Memory usage
|
||||
- `number` for Active Connections, Total Requests, etc.
|
||||
- `trend` for time-series data like requests over time
|
||||
|
||||
### 2. Update Network View (`ops-view-network.ts`)
|
||||
- Replace the current stats cards section with dees-statsgrid
|
||||
- Configure tiles for:
|
||||
- Active Connections (number)
|
||||
- Requests/sec (number with trend)
|
||||
- Throughput In/Out (number with units)
|
||||
- Protocol distribution (percentage)
|
||||
|
||||
### 3. Create Consistent Color Scheme
|
||||
- Success/Normal: #22c55e (green)
|
||||
- Warning: #f59e0b (amber)
|
||||
- Error/Critical: #ef4444 (red)
|
||||
- Info: #3b82f6 (blue)
|
||||
|
||||
### 4. Add Interactive Features
|
||||
- Click actions to show detailed views
|
||||
- Context menu for refresh, export, etc.
|
||||
- Real-time updates from metrics data
|
||||
|
||||
### 5. Integration Points
|
||||
- Connect to existing appstate for data
|
||||
- Use MetricsManager data for real values
|
||||
- Update on the 1-second refresh interval
|
||||
|
||||
## Benefits
|
||||
- Consistent UI component usage
|
||||
- Better visual hierarchy
|
||||
- Built-in responsive design
|
||||
- More visualization options (gauges, trends)
|
||||
- Reduced custom CSS maintenance
|
||||
@@ -31,38 +31,29 @@ tap.test('should login as admin', async () => {
|
||||
console.log('Admin logged in with JWT');
|
||||
});
|
||||
|
||||
tap.test('should allow admin to update configuration', async () => {
|
||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
||||
tap.test('should allow admin to verify identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'updateConfiguration'
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await updateRequest.fire({
|
||||
const response = await verifyRequest.fire({
|
||||
identity: adminIdentity,
|
||||
section: 'security',
|
||||
config: {
|
||||
rateLimit: true,
|
||||
spamDetection: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('updated');
|
||||
expect(response.updated).toBeTrue();
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
console.log('Admin identity verified successfully');
|
||||
});
|
||||
|
||||
tap.test('should reject configuration update without identity', async () => {
|
||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
||||
tap.test('should reject verify identity without identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'updateConfiguration'
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
try {
|
||||
await updateRequest.fire({
|
||||
section: 'security',
|
||||
config: {
|
||||
rateLimit: false
|
||||
}
|
||||
});
|
||||
await verifyRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
@@ -70,22 +61,18 @@ tap.test('should reject configuration update without identity', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should reject configuration update with invalid JWT', async () => {
|
||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'updateConfiguration'
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
try {
|
||||
await updateRequest.fire({
|
||||
await verifyRequest.fire({
|
||||
identity: {
|
||||
...adminIdentity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
},
|
||||
section: 'security',
|
||||
config: {
|
||||
rateLimit: false
|
||||
}
|
||||
});
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
@@ -108,6 +95,23 @@ tap.test('should allow access to public endpoints without auth', async () => {
|
||||
console.log('Public endpoint accessible without auth');
|
||||
});
|
||||
|
||||
tap.test('should allow read-only config access', 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({});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
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');
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
35
test_watch/devserver.ts
Normal file
35
test_watch/devserver.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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: []
|
||||
// },
|
||||
});
|
||||
|
||||
console.log('Starting DcRouter in development mode...');
|
||||
|
||||
await devRouter.start();
|
||||
|
||||
// Graceful shutdown handlers
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down...');
|
||||
await devRouter.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
console.log('DcRouter dev server running. Press Ctrl+C to stop.');
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '2.12.6',
|
||||
version: '4.0.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
170
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
170
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { CacheDb } from './classes.cachedb.js';
|
||||
|
||||
// Import document classes for cleanup
|
||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||
import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js';
|
||||
import { CachedBounce } from './documents/classes.cached.bounce.js';
|
||||
import { CachedSuppression } from './documents/classes.cached.suppression.js';
|
||||
import { CachedDKIMKey } from './documents/classes.cached.dkim.js';
|
||||
|
||||
/**
|
||||
* Configuration for the cache cleaner
|
||||
*/
|
||||
export interface ICacheCleanerOptions {
|
||||
/** Cleanup interval in milliseconds (default: 1 hour) */
|
||||
intervalMs?: number;
|
||||
/** Enable verbose logging */
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheCleaner - Periodically removes expired documents from the cache
|
||||
*
|
||||
* Runs on a configurable interval (default: hourly) and queries each
|
||||
* collection for documents where expiresAt < now(), then deletes them.
|
||||
*/
|
||||
export class CacheCleaner {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private options: Required<ICacheCleanerOptions>;
|
||||
private cacheDb: CacheDb;
|
||||
|
||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
||||
this.cacheDb = cacheDb;
|
||||
this.options = {
|
||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||
verbose: options.verbose || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic cleanup process
|
||||
*/
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
logger.log('warn', 'CacheCleaner already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// Run cleanup immediately on start
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||
});
|
||||
|
||||
// Schedule periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the periodic cleanup process
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
logger.log('info', 'CacheCleaner stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single cleanup cycle
|
||||
*/
|
||||
public async runCleanup(): Promise<void> {
|
||||
if (!this.cacheDb.isReady()) {
|
||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const results: { collection: string; deleted: number }[] = [];
|
||||
|
||||
try {
|
||||
// Clean CachedEmail documents
|
||||
const emailsDeleted = await this.cleanCollection(CachedEmail, now);
|
||||
results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
|
||||
|
||||
// Clean CachedIPReputation documents
|
||||
const ipReputationDeleted = await this.cleanCollection(CachedIPReputation, now);
|
||||
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
|
||||
|
||||
// Clean CachedBounce documents
|
||||
const bouncesDeleted = await this.cleanCollection(CachedBounce, now);
|
||||
results.push({ collection: 'CachedBounce', deleted: bouncesDeleted });
|
||||
|
||||
// Clean CachedSuppression documents (but not permanent ones)
|
||||
const suppressionDeleted = await this.cleanCollection(CachedSuppression, now);
|
||||
results.push({ collection: 'CachedSuppression', deleted: suppressionDeleted });
|
||||
|
||||
// Clean CachedDKIMKey documents
|
||||
const dkimDeleted = await this.cleanCollection(CachedDKIMKey, now);
|
||||
results.push({ collection: 'CachedDKIMKey', deleted: dkimDeleted });
|
||||
|
||||
// Log results
|
||||
const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
|
||||
if (totalDeleted > 0 || this.options.verbose) {
|
||||
const summary = results
|
||||
.filter((r) => r.deleted > 0)
|
||||
.map((r) => `${r.collection}: ${r.deleted}`)
|
||||
.join(', ');
|
||||
logger.log(
|
||||
'info',
|
||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired documents from a specific collection
|
||||
*/
|
||||
private async cleanCollection<T>(
|
||||
documentClass: { deleteMany: (filter: any) => Promise<any> },
|
||||
now: Date
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await documentClass.deleteMany({
|
||||
expiresAt: { $lt: now },
|
||||
});
|
||||
return result?.deletedCount || 0;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cleaner is running
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cleanup interval in milliseconds
|
||||
*/
|
||||
public getIntervalMs(): number {
|
||||
return this.options.intervalMs;
|
||||
}
|
||||
}
|
||||
108
ts/cache/classes.cached.document.ts
vendored
Normal file
108
ts/cache/classes.cached.document.ts
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Base class for all cached documents with TTL support
|
||||
*
|
||||
* Extends smartdata's SmartDataDbDoc to add:
|
||||
* - Automatic timestamps (createdAt, lastAccessedAt)
|
||||
* - TTL/expiration support (expiresAt)
|
||||
* - Helper methods for TTL management
|
||||
*/
|
||||
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
|
||||
/**
|
||||
* Timestamp when the document was created
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Timestamp when the document expires and should be cleaned up
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last access (for LRU-style eviction if needed)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Set the TTL (time to live) for this document
|
||||
* @param ttlMs Time to live in milliseconds
|
||||
*/
|
||||
public setTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using days
|
||||
* @param days Number of days until expiration
|
||||
*/
|
||||
public setTTLDays(days: number): void {
|
||||
this.setTTL(days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using hours
|
||||
* @param hours Number of hours until expiration
|
||||
*/
|
||||
public setTTLHours(hours: number): void {
|
||||
this.setTTL(hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this document has expired
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
if (!this.expiresAt) {
|
||||
return false; // No expiration set
|
||||
}
|
||||
return new Date() > this.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the lastAccessedAt timestamp
|
||||
*/
|
||||
public touch(): void {
|
||||
this.lastAccessedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining TTL in milliseconds
|
||||
* Returns 0 if expired, -1 if no expiration set
|
||||
*/
|
||||
public getRemainingTTL(): number {
|
||||
if (!this.expiresAt) {
|
||||
return -1;
|
||||
}
|
||||
const remaining = this.expiresAt.getTime() - Date.now();
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the TTL by the specified milliseconds from now
|
||||
* @param ttlMs Additional time to live in milliseconds
|
||||
*/
|
||||
public extendTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the document to never expire (100 years in the future)
|
||||
*/
|
||||
public setNeverExpires(): void {
|
||||
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL constants in milliseconds
|
||||
*/
|
||||
export const TTL = {
|
||||
HOURS_1: 1 * 60 * 60 * 1000,
|
||||
HOURS_24: 24 * 60 * 60 * 1000,
|
||||
DAYS_7: 7 * 24 * 60 * 60 * 1000,
|
||||
DAYS_30: 30 * 24 * 60 * 60 * 1000,
|
||||
DAYS_90: 90 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
152
ts/cache/classes.cachedb.ts
vendored
Normal file
152
ts/cache/classes.cachedb.ts
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Configuration options for CacheDb
|
||||
*/
|
||||
export interface ICacheDbOptions {
|
||||
/** Base storage path for TsmDB data (default: /etc/dcrouter/tsmdb) */
|
||||
storagePath?: string;
|
||||
/** Database name (default: dcrouter) */
|
||||
dbName?: string;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
||||
*
|
||||
* Provides persistent caching using smartdata as the ORM layer
|
||||
* and LocalTsmDb as the embedded database engine.
|
||||
*/
|
||||
export class CacheDb {
|
||||
private static instance: CacheDb | null = null;
|
||||
|
||||
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
||||
private smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
private options: Required<ICacheDbOptions>;
|
||||
private isStarted: boolean = false;
|
||||
|
||||
constructor(options: ICacheDbOptions = {}) {
|
||||
this.options = {
|
||||
storagePath: options.storagePath || '/etc/dcrouter/tsmdb',
|
||||
dbName: options.dbName || 'dcrouter',
|
||||
debug: options.debug || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton instance
|
||||
*/
|
||||
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
||||
if (!CacheDb.instance) {
|
||||
CacheDb.instance = new CacheDb(options);
|
||||
}
|
||||
return CacheDb.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
CacheDb.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cache database
|
||||
* - Initializes LocalTsmDb with file persistence
|
||||
* - Connects smartdata to the LocalTsmDb via Unix socket
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.log('warn', 'CacheDb already started');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||
|
||||
// Create LocalTsmDb instance
|
||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||
dbDir: this.options.storagePath,
|
||||
});
|
||||
|
||||
// Start LocalTsmDb and get connection URI
|
||||
await this.localTsmDb.start();
|
||||
const mongoDescriptor = this.localTsmDb.mongoDescriptor;
|
||||
|
||||
if (this.options.debug) {
|
||||
logger.log('debug', `LocalTsmDb started with descriptor: ${JSON.stringify(mongoDescriptor)}`);
|
||||
}
|
||||
|
||||
// Initialize smartdata with the connection
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb(mongoDescriptor);
|
||||
await this.smartdataDb.init();
|
||||
|
||||
this.isStarted = true;
|
||||
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cache database
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Close smartdata connection
|
||||
if (this.smartdataDb) {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
|
||||
// Stop LocalTsmDb
|
||||
if (this.localTsmDb) {
|
||||
await this.localTsmDb.stop();
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
logger.log('info', 'CacheDb stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smartdata database instance
|
||||
*/
|
||||
public getDb(): plugins.smartdata.SmartdataDb {
|
||||
if (!this.isStarted) {
|
||||
throw new Error('CacheDb not started. Call start() first.');
|
||||
}
|
||||
return this.smartdataDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is ready
|
||||
*/
|
||||
public isReady(): boolean {
|
||||
return this.isStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path
|
||||
*/
|
||||
public getStoragePath(): string {
|
||||
return this.options.storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database name
|
||||
*/
|
||||
public getDbName(): string {
|
||||
return this.options.dbName;
|
||||
}
|
||||
}
|
||||
244
ts/cache/documents/classes.cached.bounce.ts
vendored
Normal file
244
ts/cache/documents/classes.cached.bounce.ts
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* Bounce type classification
|
||||
*/
|
||||
export type TBounceType = 'hard' | 'soft' | 'complaint' | 'unknown';
|
||||
|
||||
/**
|
||||
* Bounce category for detailed classification
|
||||
*/
|
||||
export type TBounceCategory =
|
||||
| 'invalid-recipient'
|
||||
| 'mailbox-full'
|
||||
| 'domain-not-found'
|
||||
| 'connection-failed'
|
||||
| 'policy-rejection'
|
||||
| 'spam-rejection'
|
||||
| 'rate-limited'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* CachedBounce - Stores email bounce records
|
||||
*
|
||||
* Tracks bounce events for emails to help with deliverability
|
||||
* analysis and suppression list management.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedBounce extends CachedDocument<CachedBounce> {
|
||||
/**
|
||||
* Unique identifier for this bounce record
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
|
||||
/**
|
||||
* Email address that bounced
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public recipient: string;
|
||||
|
||||
/**
|
||||
* Sender email address
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public sender: string;
|
||||
|
||||
/**
|
||||
* Recipient domain
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string;
|
||||
|
||||
/**
|
||||
* Type of bounce (hard/soft/complaint)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bounceType: TBounceType;
|
||||
|
||||
/**
|
||||
* Detailed bounce category
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bounceCategory: TBounceCategory;
|
||||
|
||||
/**
|
||||
* SMTP response code
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public smtpCode: number;
|
||||
|
||||
/**
|
||||
* Full SMTP response message
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public smtpResponse: string;
|
||||
|
||||
/**
|
||||
* Diagnostic code from DSN
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public diagnosticCode: string;
|
||||
|
||||
/**
|
||||
* Original message ID that bounced
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public originalMessageId: string;
|
||||
|
||||
/**
|
||||
* Number of bounces for this recipient
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bounceCount: number = 1;
|
||||
|
||||
/**
|
||||
* Timestamp of the first bounce
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public firstBounceAt: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of the most recent bounce
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastBounceAt: Date;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||
this.bounceType = 'unknown';
|
||||
this.bounceCategory = 'other';
|
||||
this.firstBounceAt = new Date();
|
||||
this.lastBounceAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bounce record
|
||||
*/
|
||||
public static createNew(): CachedBounce {
|
||||
const bounce = new CachedBounce();
|
||||
bounce.id = plugins.uuid.v4();
|
||||
return bounce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bounces by recipient email
|
||||
*/
|
||||
public static async findByRecipient(recipient: string): Promise<CachedBounce[]> {
|
||||
return await CachedBounce.getInstances({
|
||||
recipient,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bounces by domain
|
||||
*/
|
||||
public static async findByDomain(domain: string): Promise<CachedBounce[]> {
|
||||
return await CachedBounce.getInstances({
|
||||
domain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all hard bounces
|
||||
*/
|
||||
public static async findHardBounces(): Promise<CachedBounce[]> {
|
||||
return await CachedBounce.getInstances({
|
||||
bounceType: 'hard',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bounces by category
|
||||
*/
|
||||
public static async findByCategory(category: TBounceCategory): Promise<CachedBounce[]> {
|
||||
return await CachedBounce.getInstances({
|
||||
bounceCategory: category,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a recipient has recent hard bounces
|
||||
*/
|
||||
public static async hasRecentHardBounce(recipient: string): Promise<boolean> {
|
||||
const bounces = await CachedBounce.getInstances({
|
||||
recipient,
|
||||
bounceType: 'hard',
|
||||
});
|
||||
return bounces.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an additional bounce for the same recipient
|
||||
*/
|
||||
public recordAdditionalBounce(smtpCode?: number, smtpResponse?: string): void {
|
||||
this.bounceCount++;
|
||||
this.lastBounceAt = new Date();
|
||||
if (smtpCode) {
|
||||
this.smtpCode = smtpCode;
|
||||
}
|
||||
if (smtpResponse) {
|
||||
this.smtpResponse = smtpResponse;
|
||||
}
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from recipient email
|
||||
*/
|
||||
public updateDomain(): void {
|
||||
if (this.recipient) {
|
||||
const match = this.recipient.match(/@([^>]+)>?$/);
|
||||
if (match) {
|
||||
this.domain = match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify bounce based on SMTP code
|
||||
*/
|
||||
public classifyFromSmtpCode(code: number): void {
|
||||
this.smtpCode = code;
|
||||
|
||||
// 5xx = permanent failure (hard bounce)
|
||||
if (code >= 500 && code < 600) {
|
||||
this.bounceType = 'hard';
|
||||
|
||||
if (code === 550) {
|
||||
this.bounceCategory = 'invalid-recipient';
|
||||
} else if (code === 551) {
|
||||
this.bounceCategory = 'policy-rejection';
|
||||
} else if (code === 552) {
|
||||
this.bounceCategory = 'mailbox-full';
|
||||
} else if (code === 553) {
|
||||
this.bounceCategory = 'invalid-recipient';
|
||||
} else if (code === 554) {
|
||||
this.bounceCategory = 'spam-rejection';
|
||||
}
|
||||
}
|
||||
// 4xx = temporary failure (soft bounce)
|
||||
else if (code >= 400 && code < 500) {
|
||||
this.bounceType = 'soft';
|
||||
|
||||
if (code === 421) {
|
||||
this.bounceCategory = 'rate-limited';
|
||||
} else if (code === 450) {
|
||||
this.bounceCategory = 'mailbox-full';
|
||||
} else if (code === 451) {
|
||||
this.bounceCategory = 'connection-failed';
|
||||
} else if (code === 452) {
|
||||
this.bounceCategory = 'rate-limited';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
ts/cache/documents/classes.cached.dkim.ts
vendored
Normal file
241
ts/cache/documents/classes.cached.dkim.ts
vendored
Normal file
@@ -0,0 +1,241 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* CachedDKIMKey - Stores DKIM key pairs for email signing
|
||||
*
|
||||
* Caches DKIM private/public key pairs per domain and selector.
|
||||
* Default TTL is 90 days (typical key rotation interval).
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedDKIMKey extends CachedDocument<CachedDKIMKey> {
|
||||
/**
|
||||
* Composite key: domain:selector
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public domainSelector: string;
|
||||
|
||||
/**
|
||||
* Domain for this DKIM key
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string;
|
||||
|
||||
/**
|
||||
* DKIM selector (e.g., 'mta', 'default', '2024')
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public selector: string;
|
||||
|
||||
/**
|
||||
* Private key in PEM format
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public privateKey: string;
|
||||
|
||||
/**
|
||||
* Public key in PEM format
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public publicKey: string;
|
||||
|
||||
/**
|
||||
* Public key for DNS TXT record (base64, no headers)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public publicKeyDns: string;
|
||||
|
||||
/**
|
||||
* Key size in bits (e.g., 1024, 2048)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public keySize: number = 2048;
|
||||
|
||||
/**
|
||||
* Key algorithm (e.g., 'rsa-sha256')
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public algorithm: string = 'rsa-sha256';
|
||||
|
||||
/**
|
||||
* When the key was generated
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public generatedAt: Date;
|
||||
|
||||
/**
|
||||
* When the key was last rotated
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rotatedAt: Date;
|
||||
|
||||
/**
|
||||
* Previous selector (for key rotation)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public previousSelector: string;
|
||||
|
||||
/**
|
||||
* Number of emails signed with this key
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public signCount: number = 0;
|
||||
|
||||
/**
|
||||
* Whether this key is currently active
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isActive: boolean = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_90); // Default 90-day TTL
|
||||
this.generatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the composite key from domain and selector
|
||||
*/
|
||||
public static createDomainSelector(domain: string, selector: string): string {
|
||||
return `${domain.toLowerCase()}:${selector.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new DKIM key entry
|
||||
*/
|
||||
public static createNew(domain: string, selector: string): CachedDKIMKey {
|
||||
const key = new CachedDKIMKey();
|
||||
key.domain = domain.toLowerCase();
|
||||
key.selector = selector.toLowerCase();
|
||||
key.domainSelector = CachedDKIMKey.createDomainSelector(domain, selector);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by domain and selector
|
||||
*/
|
||||
public static async findByDomainSelector(
|
||||
domain: string,
|
||||
selector: string
|
||||
): Promise<CachedDKIMKey | null> {
|
||||
const domainSelector = CachedDKIMKey.createDomainSelector(domain, selector);
|
||||
return await CachedDKIMKey.getInstance({
|
||||
domainSelector,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all keys for a domain
|
||||
*/
|
||||
public static async findByDomain(domain: string): Promise<CachedDKIMKey[]> {
|
||||
return await CachedDKIMKey.getInstances({
|
||||
domain: domain.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the active key for a domain
|
||||
*/
|
||||
public static async findActiveForDomain(domain: string): Promise<CachedDKIMKey | null> {
|
||||
const keys = await CachedDKIMKey.getInstances({
|
||||
domain: domain.toLowerCase(),
|
||||
isActive: true,
|
||||
});
|
||||
return keys.length > 0 ? keys[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all active keys
|
||||
*/
|
||||
public static async findAllActive(): Promise<CachedDKIMKey[]> {
|
||||
return await CachedDKIMKey.getInstances({
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the key pair
|
||||
*/
|
||||
public setKeyPair(privateKey: string, publicKey: string, publicKeyDns?: string): void {
|
||||
this.privateKey = privateKey;
|
||||
this.publicKey = publicKey;
|
||||
this.publicKeyDns = publicKeyDns || this.extractPublicKeyDns(publicKey);
|
||||
this.generatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base64 public key for DNS from PEM format
|
||||
*/
|
||||
private extractPublicKeyDns(publicKeyPem: string): string {
|
||||
// Remove PEM headers and newlines
|
||||
return publicKeyPem
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||
.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the DNS TXT record value
|
||||
*/
|
||||
public getDnsTxtRecord(): string {
|
||||
return `v=DKIM1; k=rsa; p=${this.publicKeyDns}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full DNS record name
|
||||
*/
|
||||
public getDnsRecordName(): string {
|
||||
return `${this.selector}._domainkey.${this.domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that this key was used to sign an email
|
||||
*/
|
||||
public recordSign(): void {
|
||||
this.signCount++;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate this key (e.g., during rotation)
|
||||
*/
|
||||
public deactivate(): void {
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate this key
|
||||
*/
|
||||
public activate(): void {
|
||||
this.isActive = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate to a new selector
|
||||
*/
|
||||
public rotate(newSelector: string): void {
|
||||
this.previousSelector = this.selector;
|
||||
this.selector = newSelector.toLowerCase();
|
||||
this.domainSelector = CachedDKIMKey.createDomainSelector(this.domain, this.selector);
|
||||
this.rotatedAt = new Date();
|
||||
this.signCount = 0;
|
||||
// Reset TTL on rotation
|
||||
this.setTTL(TTL.DAYS_90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key needs rotation (based on age or sign count)
|
||||
*/
|
||||
public needsRotation(maxAgeDays: number = 90, maxSignCount: number = 1000000): boolean {
|
||||
const ageMs = Date.now() - this.generatedAt.getTime();
|
||||
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
||||
return ageDays > maxAgeDays || this.signCount > maxSignCount;
|
||||
}
|
||||
}
|
||||
230
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
230
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Email status in the cache
|
||||
*/
|
||||
export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* CachedEmail - Stores email queue items in the cache
|
||||
*
|
||||
* Used for persistent email queue storage, tracking delivery status,
|
||||
* and maintaining email history for the configured TTL period.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
/**
|
||||
* Unique identifier for this email
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
|
||||
/**
|
||||
* Email message ID (RFC 822 Message-ID header)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public messageId: string;
|
||||
|
||||
/**
|
||||
* Sender email address (envelope from)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public from: string;
|
||||
|
||||
/**
|
||||
* Recipient email addresses
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public to: string[];
|
||||
|
||||
/**
|
||||
* CC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public cc: string[];
|
||||
|
||||
/**
|
||||
* BCC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bcc: string[];
|
||||
|
||||
/**
|
||||
* Email subject
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public subject: string;
|
||||
|
||||
/**
|
||||
* Raw RFC822 email content
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rawContent: string;
|
||||
|
||||
/**
|
||||
* Current status of the email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TCachedEmailStatus;
|
||||
|
||||
/**
|
||||
* Number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public attempts: number = 0;
|
||||
|
||||
/**
|
||||
* Maximum number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public maxAttempts: number = 3;
|
||||
|
||||
/**
|
||||
* Timestamp for next delivery attempt
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public nextAttempt: Date;
|
||||
|
||||
/**
|
||||
* Last error message if delivery failed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the email was successfully delivered
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public deliveredAt: Date;
|
||||
|
||||
/**
|
||||
* Sender domain (for querying/filtering)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public senderDomain: string;
|
||||
|
||||
/**
|
||||
* Priority level (higher = more important)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public priority: number = 0;
|
||||
|
||||
/**
|
||||
* JSON-serialized route data
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public routeData: string;
|
||||
|
||||
/**
|
||||
* DKIM signature status
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public dkimSigned: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||
this.status = 'pending';
|
||||
this.to = [];
|
||||
this.cc = [];
|
||||
this.bcc = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new CachedEmail with a unique ID
|
||||
*/
|
||||
public static createNew(): CachedEmail {
|
||||
const email = new CachedEmail();
|
||||
email.id = plugins.uuid.v4();
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an email by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<CachedEmail | null> {
|
||||
return await CachedEmail.getInstance({
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails with a specific status
|
||||
*/
|
||||
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails pending delivery (status = pending and nextAttempt <= now)
|
||||
*/
|
||||
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
|
||||
const now = new Date();
|
||||
return await CachedEmail.getInstances({
|
||||
status: 'pending',
|
||||
nextAttempt: { $lte: now },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find emails by sender domain
|
||||
*/
|
||||
public static async findBySenderDomain(domain: string): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
senderDomain: domain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as delivered
|
||||
*/
|
||||
public markDelivered(): void {
|
||||
this.status = 'delivered';
|
||||
this.deliveredAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as failed with error
|
||||
*/
|
||||
public markFailed(error: string): void {
|
||||
this.status = 'failed';
|
||||
this.lastError = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment attempt counter and schedule next attempt
|
||||
*/
|
||||
public scheduleRetry(delayMs: number = 5 * 60 * 1000): void {
|
||||
this.attempts++;
|
||||
this.status = 'deferred';
|
||||
this.nextAttempt = new Date(Date.now() + delayMs);
|
||||
|
||||
// If max attempts reached, mark as failed
|
||||
if (this.attempts >= this.maxAttempts) {
|
||||
this.status = 'failed';
|
||||
this.lastError = `Max attempts (${this.maxAttempts}) reached`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sender domain from email address
|
||||
*/
|
||||
public updateSenderDomain(): void {
|
||||
if (this.from) {
|
||||
const match = this.from.match(/@([^>]+)>?$/);
|
||||
if (match) {
|
||||
this.senderDomain = match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
237
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
237
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* IP reputation result data
|
||||
*/
|
||||
export interface IIPReputationData {
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
isProxy: boolean;
|
||||
isTor: boolean;
|
||||
isVPN: boolean;
|
||||
country?: string;
|
||||
asn?: string;
|
||||
org?: string;
|
||||
blacklists?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CachedIPReputation - Stores IP reputation lookup results
|
||||
*
|
||||
* Caches the results of IP reputation checks to avoid repeated
|
||||
* external API calls. Default TTL is 24 hours.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
/**
|
||||
* IP address (unique identifier)
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress: string;
|
||||
|
||||
/**
|
||||
* Reputation score (0-100, higher = better)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public score: number;
|
||||
|
||||
/**
|
||||
* Whether the IP is flagged as spam source
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isSpam: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a known proxy
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isProxy: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a Tor exit node
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isTor: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a VPN endpoint
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isVPN: boolean;
|
||||
|
||||
/**
|
||||
* Country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string;
|
||||
|
||||
/**
|
||||
* Autonomous System Number
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: string;
|
||||
|
||||
/**
|
||||
* Organization name
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public org: string;
|
||||
|
||||
/**
|
||||
* List of blacklists the IP appears on
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blacklists: string[];
|
||||
|
||||
/**
|
||||
* Number of times this IP has been checked
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public checkCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of connections from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public connectionCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of emails received from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public emailCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of spam emails from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public spamCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.HOURS_24); // Default 24-hour TTL
|
||||
this.blacklists = [];
|
||||
this.score = 50; // Default neutral score
|
||||
this.isSpam = false;
|
||||
this.isProxy = false;
|
||||
this.isTor = false;
|
||||
this.isVPN = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from reputation data
|
||||
*/
|
||||
public static fromReputationData(ipAddress: string, data: IIPReputationData): CachedIPReputation {
|
||||
const cached = new CachedIPReputation();
|
||||
cached.ipAddress = ipAddress;
|
||||
cached.score = data.score;
|
||||
cached.isSpam = data.isSpam;
|
||||
cached.isProxy = data.isProxy;
|
||||
cached.isTor = data.isTor;
|
||||
cached.isVPN = data.isVPN;
|
||||
cached.country = data.country || '';
|
||||
cached.asn = data.asn || '';
|
||||
cached.org = data.org || '';
|
||||
cached.blacklists = data.blacklists || [];
|
||||
cached.checkCount = 1;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to reputation data object
|
||||
*/
|
||||
public toReputationData(): IIPReputationData {
|
||||
this.touch();
|
||||
return {
|
||||
score: this.score,
|
||||
isSpam: this.isSpam,
|
||||
isProxy: this.isProxy,
|
||||
isTor: this.isTor,
|
||||
isVPN: this.isVPN,
|
||||
country: this.country,
|
||||
asn: this.asn,
|
||||
org: this.org,
|
||||
blacklists: this.blacklists,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by IP address
|
||||
*/
|
||||
public static async findByIP(ipAddress: string): Promise<CachedIPReputation | null> {
|
||||
return await CachedIPReputation.getInstance({
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all IPs flagged as spam
|
||||
*/
|
||||
public static async findSpamIPs(): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
isSpam: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find IPs with score below threshold
|
||||
*/
|
||||
public static async findLowScoreIPs(threshold: number): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
score: { $lt: threshold },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a connection from this IP
|
||||
*/
|
||||
public recordConnection(): void {
|
||||
this.connectionCount++;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an email from this IP
|
||||
*/
|
||||
public recordEmail(isSpam: boolean = false): void {
|
||||
this.emailCount++;
|
||||
if (isSpam) {
|
||||
this.spamCount++;
|
||||
}
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the reputation data
|
||||
*/
|
||||
public updateReputation(data: IIPReputationData): void {
|
||||
this.score = data.score;
|
||||
this.isSpam = data.isSpam;
|
||||
this.isProxy = data.isProxy;
|
||||
this.isTor = data.isTor;
|
||||
this.isVPN = data.isVPN;
|
||||
this.country = data.country || this.country;
|
||||
this.asn = data.asn || this.asn;
|
||||
this.org = data.org || this.org;
|
||||
this.blacklists = data.blacklists || this.blacklists;
|
||||
this.checkCount++;
|
||||
this.touch();
|
||||
// Refresh TTL on update
|
||||
this.setTTL(TTL.HOURS_24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this IP should be blocked
|
||||
*/
|
||||
public shouldBlock(): boolean {
|
||||
return this.isSpam || this.score < 20 || this.blacklists.length > 2;
|
||||
}
|
||||
}
|
||||
262
ts/cache/documents/classes.cached.suppression.ts
vendored
Normal file
262
ts/cache/documents/classes.cached.suppression.ts
vendored
Normal file
@@ -0,0 +1,262 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* Reason for suppression
|
||||
*/
|
||||
export type TSuppressionReason =
|
||||
| 'hard-bounce'
|
||||
| 'soft-bounce-exceeded'
|
||||
| 'complaint'
|
||||
| 'unsubscribe'
|
||||
| 'manual'
|
||||
| 'spam-trap'
|
||||
| 'invalid-address';
|
||||
|
||||
/**
|
||||
* CachedSuppression - Stores email suppression list entries
|
||||
*
|
||||
* Emails to addresses in the suppression list should not be sent.
|
||||
* Supports both temporary (30-day) and permanent suppression.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedSuppression extends CachedDocument<CachedSuppression> {
|
||||
/**
|
||||
* Email address to suppress (unique identifier)
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public email: string;
|
||||
|
||||
/**
|
||||
* Reason for suppression
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public reason: TSuppressionReason;
|
||||
|
||||
/**
|
||||
* Human-readable description of why this address is suppressed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public description: string;
|
||||
|
||||
/**
|
||||
* Whether this is a permanent suppression
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public permanent: boolean = false;
|
||||
|
||||
/**
|
||||
* Number of times we've tried to send to this address after suppression
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blockedAttempts: number = 0;
|
||||
|
||||
/**
|
||||
* Domain of the suppressed email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string;
|
||||
|
||||
/**
|
||||
* Related bounce record ID (if suppressed due to bounce)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public relatedBounceId: string;
|
||||
|
||||
/**
|
||||
* Source that caused the suppression (e.g., campaign ID, message ID)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public source: string;
|
||||
|
||||
/**
|
||||
* Date when the suppression was first created
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public suppressedAt: Date;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||
this.suppressedAt = new Date();
|
||||
this.blockedAttempts = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new suppression entry
|
||||
*/
|
||||
public static createNew(email: string, reason: TSuppressionReason): CachedSuppression {
|
||||
const suppression = new CachedSuppression();
|
||||
suppression.email = email.toLowerCase().trim();
|
||||
suppression.reason = reason;
|
||||
suppression.updateDomain();
|
||||
|
||||
// Hard bounces and spam traps should be permanent
|
||||
if (reason === 'hard-bounce' || reason === 'spam-trap' || reason === 'complaint') {
|
||||
suppression.setPermanent();
|
||||
}
|
||||
|
||||
return suppression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this suppression permanent (never expires)
|
||||
*/
|
||||
public setPermanent(): void {
|
||||
this.permanent = true;
|
||||
this.setNeverExpires();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this suppression temporary with specific TTL
|
||||
*/
|
||||
public setTemporary(ttlMs: number): void {
|
||||
this.permanent = false;
|
||||
this.setTTL(ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from email
|
||||
*/
|
||||
public updateDomain(): void {
|
||||
if (this.email) {
|
||||
const match = this.email.match(/@(.+)$/);
|
||||
if (match) {
|
||||
this.domain = match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an email is suppressed
|
||||
*/
|
||||
public static async isSuppressed(email: string): Promise<boolean> {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const entry = await CachedSuppression.getInstance({
|
||||
email: normalizedEmail,
|
||||
});
|
||||
return entry !== null && !entry.isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suppression entry for an email
|
||||
*/
|
||||
public static async findByEmail(email: string): Promise<CachedSuppression | null> {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
return await CachedSuppression.getInstance({
|
||||
email: normalizedEmail,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all suppressions for a domain
|
||||
*/
|
||||
public static async findByDomain(domain: string): Promise<CachedSuppression[]> {
|
||||
return await CachedSuppression.getInstances({
|
||||
domain: domain.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all permanent suppressions
|
||||
*/
|
||||
public static async findPermanent(): Promise<CachedSuppression[]> {
|
||||
return await CachedSuppression.getInstances({
|
||||
permanent: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all suppressions by reason
|
||||
*/
|
||||
public static async findByReason(reason: TSuppressionReason): Promise<CachedSuppression[]> {
|
||||
return await CachedSuppression.getInstances({
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a blocked attempt to send to this address
|
||||
*/
|
||||
public recordBlockedAttempt(): void {
|
||||
this.blockedAttempts++;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove suppression (delete from database)
|
||||
*/
|
||||
public static async remove(email: string): Promise<boolean> {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const entry = await CachedSuppression.getInstance({
|
||||
email: normalizedEmail,
|
||||
});
|
||||
if (entry) {
|
||||
await entry.delete();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a suppression entry
|
||||
*/
|
||||
public static async addOrUpdate(
|
||||
email: string,
|
||||
reason: TSuppressionReason,
|
||||
options?: {
|
||||
permanent?: boolean;
|
||||
description?: string;
|
||||
source?: string;
|
||||
relatedBounceId?: string;
|
||||
}
|
||||
): Promise<CachedSuppression> {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
// Check if already suppressed
|
||||
let entry = await CachedSuppression.findByEmail(normalizedEmail);
|
||||
|
||||
if (entry) {
|
||||
// Update existing entry
|
||||
entry.reason = reason;
|
||||
if (options?.permanent) {
|
||||
entry.setPermanent();
|
||||
}
|
||||
if (options?.description) {
|
||||
entry.description = options.description;
|
||||
}
|
||||
if (options?.source) {
|
||||
entry.source = options.source;
|
||||
}
|
||||
if (options?.relatedBounceId) {
|
||||
entry.relatedBounceId = options.relatedBounceId;
|
||||
}
|
||||
entry.touch();
|
||||
} else {
|
||||
// Create new entry
|
||||
entry = CachedSuppression.createNew(normalizedEmail, reason);
|
||||
if (options?.permanent) {
|
||||
entry.setPermanent();
|
||||
}
|
||||
if (options?.description) {
|
||||
entry.description = options.description;
|
||||
}
|
||||
if (options?.source) {
|
||||
entry.source = options.source;
|
||||
}
|
||||
if (options?.relatedBounceId) {
|
||||
entry.relatedBounceId = options.relatedBounceId;
|
||||
}
|
||||
}
|
||||
|
||||
await entry.save();
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
5
ts/cache/documents/index.ts
vendored
Normal file
5
ts/cache/documents/index.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
export * from './classes.cached.bounce.js';
|
||||
export * from './classes.cached.suppression.js';
|
||||
export * from './classes.cached.dkim.js';
|
||||
7
ts/cache/index.ts
vendored
Normal file
7
ts/cache/index.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Core cache infrastructure
|
||||
export * from './classes.cachedb.js';
|
||||
export * from './classes.cached.document.js';
|
||||
export * from './classes.cache.cleaner.js';
|
||||
|
||||
// Document classes
|
||||
export * from './documents/index.js';
|
||||
@@ -14,6 +14,7 @@ import { StorageManager, type IStorageConfig } from './storage/index.js';
|
||||
|
||||
import { OpsServer } from './opsserver/index.js';
|
||||
import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/**
|
||||
@@ -109,6 +110,12 @@ export interface IDcRouterOptions {
|
||||
|
||||
/** Storage configuration */
|
||||
storage?: IStorageConfig;
|
||||
|
||||
/**
|
||||
* RADIUS server configuration for network authentication
|
||||
* Enables MAC Authentication Bypass (MAB) and VLAN assignment
|
||||
*/
|
||||
radiusConfig?: IRadiusServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +139,7 @@ export class DcRouter {
|
||||
public smartProxy?: plugins.smartproxy.SmartProxy;
|
||||
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
|
||||
public emailServer?: UnifiedEmailServer;
|
||||
public radiusServer?: RadiusServer;
|
||||
public storageManager: StorageManager;
|
||||
public opsServer: OpsServer;
|
||||
public metricsManager?: MetricsManager;
|
||||
@@ -186,6 +194,11 @@ export class DcRouter {
|
||||
await this.setupDnsWithSocketHandler();
|
||||
}
|
||||
|
||||
// Set up RADIUS server if configured
|
||||
if (this.options.radiusConfig) {
|
||||
await this.setupRadiusServer();
|
||||
}
|
||||
|
||||
this.logStartupSummary();
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting DcRouter:', error);
|
||||
@@ -261,6 +274,17 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// RADIUS service summary
|
||||
if (this.radiusServer && this.options.radiusConfig) {
|
||||
console.log('\n🔐 RADIUS Service:');
|
||||
console.log(` ├─ Auth Port: ${this.options.radiusConfig.authPort || 1812}`);
|
||||
console.log(` ├─ Acct Port: ${this.options.radiusConfig.acctPort || 1813}`);
|
||||
console.log(` ├─ Clients configured: ${this.options.radiusConfig.clients?.length || 0}`);
|
||||
const vlanStats = this.radiusServer.getVlanManager().getStats();
|
||||
console.log(` ├─ VLAN mappings: ${vlanStats.totalMappings}`);
|
||||
console.log(` └─ Accounting: ${this.options.radiusConfig.accounting?.enabled ? 'Enabled' : 'Disabled'}`);
|
||||
}
|
||||
|
||||
// Storage summary
|
||||
if (this.storageManager && this.options.storage) {
|
||||
console.log('\n💾 Storage:');
|
||||
@@ -493,10 +517,10 @@ export class DcRouter {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: route.action.type === 'forward' && route.action.forward ? {
|
||||
targets: route.action.type === 'forward' && route.action.forward ? [{
|
||||
host: route.action.forward.host,
|
||||
port: route.action.forward.port || 25
|
||||
} : undefined,
|
||||
}] : undefined,
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
@@ -592,6 +616,11 @@ export class DcRouter {
|
||||
// Stop DNS server if running
|
||||
this.dnsServer ?
|
||||
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) :
|
||||
Promise.resolve(),
|
||||
|
||||
// Stop RADIUS server if running
|
||||
this.radiusServer ?
|
||||
this.radiusServer.stop().catch(err => console.error('Error stopping RADIUS server:', err)) :
|
||||
Promise.resolve()
|
||||
]);
|
||||
|
||||
@@ -1338,9 +1367,47 @@ export class DcRouter {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up RADIUS server for network authentication
|
||||
*/
|
||||
private async setupRadiusServer(): Promise<void> {
|
||||
if (!this.options.radiusConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', 'Setting up RADIUS server...');
|
||||
|
||||
this.radiusServer = new RadiusServer(this.options.radiusConfig, this.storageManager);
|
||||
await this.radiusServer.start();
|
||||
|
||||
logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update RADIUS configuration at runtime
|
||||
*/
|
||||
public async updateRadiusConfig(config: IRadiusServerConfig): Promise<void> {
|
||||
// Stop existing RADIUS server if running
|
||||
if (this.radiusServer) {
|
||||
await this.radiusServer.stop();
|
||||
this.radiusServer = undefined;
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
this.options.radiusConfig = config;
|
||||
|
||||
// Start with new configuration
|
||||
await this.setupRadiusServer();
|
||||
|
||||
logger.log('info', 'RADIUS configuration updated');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export email server types for convenience
|
||||
export type { IUnifiedEmailServerOptions };
|
||||
|
||||
// Re-export RADIUS types for convenience
|
||||
export type { IRadiusServerConfig };
|
||||
|
||||
export default DcRouter;
|
||||
|
||||
@@ -4,4 +4,7 @@ export * from './mail/index.js';
|
||||
// DcRouter
|
||||
export * from './classes.dcrouter.js';
|
||||
|
||||
// RADIUS module
|
||||
export * from './radius/index.js';
|
||||
|
||||
export const runCli = async () => {}
|
||||
@@ -16,6 +16,8 @@ export class OpsServer {
|
||||
private logsHandler: handlers.LogsHandler;
|
||||
private securityHandler: handlers.SecurityHandler;
|
||||
private statsHandler: handlers.StatsHandler;
|
||||
private radiusHandler: handlers.RadiusHandler;
|
||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -53,6 +55,8 @@ export class OpsServer {
|
||||
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);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export class AdminHandler {
|
||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||
}
|
||||
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24 * 7; // 7 days
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId: user.id,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireAdminIdentity } from '../helpers/guards.js';
|
||||
|
||||
export class ConfigHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -13,7 +12,7 @@ export class ConfigHandler {
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Configuration Handler
|
||||
// Get Configuration Handler (read-only)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
@@ -26,33 +25,6 @@ export class ConfigHandler {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Update Configuration Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateConfiguration>(
|
||||
'updateConfiguration',
|
||||
async (dataArg, toolsArg) => {
|
||||
try {
|
||||
// Require admin access to update configuration
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const updatedConfig = await this.updateConfiguration(dataArg.section, dataArg.config);
|
||||
return {
|
||||
updated: true,
|
||||
config: updatedConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
updated: false,
|
||||
config: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfiguration(section?: string): Promise<{
|
||||
@@ -133,31 +105,4 @@ export class ConfigHandler {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async updateConfiguration(section: string, config: any): Promise<any> {
|
||||
// TODO: Implement actual configuration updates
|
||||
// This would involve:
|
||||
// 1. Validating the configuration changes
|
||||
// 2. Applying them to the running services
|
||||
// 3. Persisting them to storage
|
||||
// 4. Potentially restarting affected services
|
||||
|
||||
// For now, just validate and return the config
|
||||
if (section === 'email' && config.maxMessageSize && config.maxMessageSize < 1024) {
|
||||
throw new Error('Maximum message size must be at least 1KB');
|
||||
}
|
||||
|
||||
if (section === 'dns' && config.ttl && (config.ttl < 0 || config.ttl > 86400)) {
|
||||
throw new Error('DNS TTL must be between 0 and 86400 seconds');
|
||||
}
|
||||
|
||||
if (section === 'proxy' && config.maxConnections && config.maxConnections < 1) {
|
||||
throw new Error('Maximum connections must be at least 1');
|
||||
}
|
||||
|
||||
// In a real implementation, apply the changes here
|
||||
// For now, return the current configuration
|
||||
const currentConfig = await this.getConfiguration(section);
|
||||
return currentConfig;
|
||||
}
|
||||
}
|
||||
325
ts/opsserver/handlers/email-ops.handler.ts
Normal file
325
ts/opsserver/handlers/email-ops.handler.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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',
|
||||
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,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Sent Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
|
||||
'getSentEmails',
|
||||
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
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Resend Failed Email Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return { success: false, error: 'Email server not available' };
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(dataArg.emailId);
|
||||
|
||||
if (!item) {
|
||||
return { success: false, error: 'Email not found in queue' };
|
||||
}
|
||||
|
||||
if (item.status !== 'failed') {
|
||||
return { success: false, error: `Email is not in failed state (current: ${item.status})` };
|
||||
}
|
||||
|
||||
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 {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to resend email'
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
// Get bounce manager from email server via reflection
|
||||
// BounceManager is private but we need to access it
|
||||
const bounceManager = (emailServer as any)?.bounceManager;
|
||||
|
||||
if (!bounceManager) {
|
||||
return { records: [], suppressionList: [], total: 0 };
|
||||
}
|
||||
|
||||
// Get suppression list
|
||||
const suppressionList = bounceManager.getSuppressionList();
|
||||
|
||||
// Get hard bounced addresses and convert to records
|
||||
const hardBouncedAddresses = bounceManager.getHardBouncedAddresses();
|
||||
|
||||
// Create bounce records from the available data
|
||||
const records: interfaces.requests.IBounceRecord[] = [];
|
||||
|
||||
for (const email of hardBouncedAddresses) {
|
||||
const bounceInfo = bounceManager.getBounceInfo(email);
|
||||
if (bounceInfo) {
|
||||
records.push({
|
||||
id: `bounce-${email}`,
|
||||
recipient: email,
|
||||
sender: '',
|
||||
domain: email.split('@')[1] || '',
|
||||
bounceType: bounceInfo.type as interfaces.requests.TBounceType,
|
||||
bounceCategory: bounceInfo.category as interfaces.requests.TBounceCategory,
|
||||
timestamp: bounceInfo.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;
|
||||
const bounceManager = (emailServer as any)?.bounceManager;
|
||||
|
||||
if (!bounceManager) {
|
||||
return { success: false, error: 'Bounce manager not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
bounceManager.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
|
||||
*/
|
||||
private getQueueItems(
|
||||
status?: interfaces.requests.TEmailQueueStatus,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): interfaces.requests.IEmailQueueItem[] {
|
||||
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
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
// Apply pagination
|
||||
return items.slice(offset, offset + limit);
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export * from './config.handler.js';
|
||||
export * from './logs.handler.js';
|
||||
export * from './security.handler.js';
|
||||
export * from './stats.handler.js';
|
||||
export * from './radius.handler.js';
|
||||
export * from './email-ops.handler.js';
|
||||
405
ts/opsserver/handlers/radius.handler.ts
Normal file
405
ts/opsserver/handlers/radius.handler.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
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 {
|
||||
// ========================================================================
|
||||
// RADIUS Client Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all RADIUS clients
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||
'getRadiusClients',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { clients: [] };
|
||||
}
|
||||
|
||||
const clients = radiusServer.getClients();
|
||||
return {
|
||||
clients: clients.map(c => ({
|
||||
name: c.name,
|
||||
ipRange: c.ipRange,
|
||||
description: c.description,
|
||||
enabled: c.enabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||
'setRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
await radiusServer.addClient(dataArg.client);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||
'removeRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const removed = radiusServer.removeClient(dataArg.name);
|
||||
return {
|
||||
success: removed,
|
||||
message: removed ? undefined : 'Client not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// VLAN Mapping Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all VLAN mappings
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||
'getVlanMappings',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
mappings: [],
|
||||
config: { defaultVlan: 1, allowUnknownMacs: true },
|
||||
};
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const mappings = vlanManager.getAllMappings();
|
||||
const config = vlanManager.getConfig();
|
||||
|
||||
return {
|
||||
mappings: mappings.map(m => ({
|
||||
mac: m.mac,
|
||||
vlan: m.vlan,
|
||||
description: m.description,
|
||||
enabled: m.enabled,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
})),
|
||||
config: {
|
||||
defaultVlan: config.defaultVlan,
|
||||
allowUnknownMacs: config.allowUnknownMacs,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||
'setVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const mapping = await vlanManager.addMapping(dataArg.mapping);
|
||||
return {
|
||||
success: true,
|
||||
mapping: {
|
||||
mac: mapping.mac,
|
||||
vlan: mapping.vlan,
|
||||
description: mapping.description,
|
||||
enabled: mapping.enabled,
|
||||
createdAt: mapping.createdAt,
|
||||
updatedAt: mapping.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||
'removeVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const removed = await vlanManager.removeMapping(dataArg.mac);
|
||||
return {
|
||||
success: removed,
|
||||
message: removed ? undefined : 'Mapping not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Update VLAN configuration
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||
'updateVlanConfig',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
success: false,
|
||||
config: { defaultVlan: 1, allowUnknownMacs: true },
|
||||
};
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
vlanManager.updateConfig({
|
||||
defaultVlan: dataArg.defaultVlan,
|
||||
allowUnknownMacs: dataArg.allowUnknownMacs,
|
||||
});
|
||||
|
||||
const config = vlanManager.getConfig();
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
defaultVlan: config.defaultVlan,
|
||||
allowUnknownMacs: config.allowUnknownMacs,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Test VLAN assignment
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||
'testVlanAssignment',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { assigned: false, vlan: 0, isDefault: false };
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const result = vlanManager.assignVlan(dataArg.mac);
|
||||
|
||||
return {
|
||||
assigned: result.assigned,
|
||||
vlan: result.vlan,
|
||||
isDefault: result.isDefault,
|
||||
matchedRule: result.matchedRule
|
||||
? {
|
||||
mac: result.matchedRule.mac,
|
||||
vlan: result.matchedRule.vlan,
|
||||
description: result.matchedRule.description,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// Accounting / Session Management
|
||||
// ========================================================================
|
||||
|
||||
// Get active sessions
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||
'getRadiusSessions',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { sessions: [], totalCount: 0 };
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
let sessions = accountingManager.getActiveSessions();
|
||||
|
||||
// Apply filters
|
||||
if (dataArg.filter) {
|
||||
if (dataArg.filter.username) {
|
||||
sessions = sessions.filter(s => s.username === dataArg.filter!.username);
|
||||
}
|
||||
if (dataArg.filter.nasIpAddress) {
|
||||
sessions = sessions.filter(s => s.nasIpAddress === dataArg.filter!.nasIpAddress);
|
||||
}
|
||||
if (dataArg.filter.vlanId !== undefined) {
|
||||
sessions = sessions.filter(s => s.vlanId === dataArg.filter!.vlanId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: sessions.map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
username: s.username,
|
||||
macAddress: s.macAddress,
|
||||
nasIpAddress: s.nasIpAddress,
|
||||
nasIdentifier: s.nasIdentifier,
|
||||
vlanId: s.vlanId,
|
||||
framedIpAddress: s.framedIpAddress,
|
||||
startTime: s.startTime,
|
||||
lastUpdateTime: s.lastUpdateTime,
|
||||
status: s.status,
|
||||
inputOctets: s.inputOctets,
|
||||
outputOctets: s.outputOctets,
|
||||
sessionTime: s.sessionTime,
|
||||
})),
|
||||
totalCount: sessions.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Disconnect a session
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||
'disconnectRadiusSession',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
const disconnected = await accountingManager.disconnectSession(
|
||||
dataArg.sessionId,
|
||||
dataArg.reason || 'AdminReset'
|
||||
);
|
||||
|
||||
return {
|
||||
success: disconnected,
|
||||
message: disconnected ? undefined : 'Session not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get accounting summary
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||
'getRadiusAccountingSummary',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
summary: {
|
||||
periodStart: dataArg.startTime,
|
||||
periodEnd: dataArg.endTime,
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalInputBytes: 0,
|
||||
totalOutputBytes: 0,
|
||||
totalSessionTime: 0,
|
||||
averageSessionDuration: 0,
|
||||
uniqueUsers: 0,
|
||||
sessionsByVlan: {},
|
||||
topUsersByTraffic: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
const summary = await accountingManager.getSummary(dataArg.startTime, dataArg.endTime);
|
||||
|
||||
return { summary };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// Statistics
|
||||
// ========================================================================
|
||||
|
||||
// Get RADIUS statistics
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||
'getRadiusStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
stats: {
|
||||
running: false,
|
||||
uptime: 0,
|
||||
authRequests: 0,
|
||||
authAccepts: 0,
|
||||
authRejects: 0,
|
||||
accountingRequests: 0,
|
||||
activeSessions: 0,
|
||||
vlanMappings: 0,
|
||||
clients: 0,
|
||||
},
|
||||
vlanStats: {
|
||||
totalMappings: 0,
|
||||
enabledMappings: 0,
|
||||
exactMatches: 0,
|
||||
ouiPatterns: 0,
|
||||
wildcardPatterns: 0,
|
||||
},
|
||||
accountingStats: {
|
||||
activeSessions: 0,
|
||||
totalSessionsStarted: 0,
|
||||
totalSessionsStopped: 0,
|
||||
totalInputBytes: 0,
|
||||
totalOutputBytes: 0,
|
||||
interimUpdatesReceived: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stats = radiusServer.getStats();
|
||||
const vlanStats = radiusServer.getVlanManager().getStats();
|
||||
const accountingStats = radiusServer.getAccountingManager().getStats();
|
||||
|
||||
return {
|
||||
stats,
|
||||
vlanStats,
|
||||
accountingStats,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,16 +51,18 @@ import * as smartjwt from '@push.rocks/smartjwt';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartmail from '@push.rocks/smartmail';
|
||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartproxy from '@push.rocks/smartproxy';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartradius from '@push.rocks/smartradius';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrule from '@push.rocks/smartrule';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx, smartunique };
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartmongo, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrule, smartrx, smartunique };
|
||||
|
||||
// Define SmartLog types for use in error handling
|
||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||
|
||||
607
ts/radius/classes.accounting.manager.ts
Normal file
607
ts/radius/classes.accounting.manager.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
|
||||
/**
|
||||
* RADIUS accounting session
|
||||
*/
|
||||
export interface IAccountingSession {
|
||||
/** Unique session ID from RADIUS */
|
||||
sessionId: string;
|
||||
/** Username (often MAC address for MAB) */
|
||||
username: string;
|
||||
/** MAC address of the device */
|
||||
macAddress?: string;
|
||||
/** NAS IP address (switch/AP) */
|
||||
nasIpAddress: string;
|
||||
/** NAS port (physical or virtual) */
|
||||
nasPort?: number;
|
||||
/** NAS port type */
|
||||
nasPortType?: string;
|
||||
/** NAS identifier (name) */
|
||||
nasIdentifier?: string;
|
||||
/** Assigned VLAN */
|
||||
vlanId?: number;
|
||||
/** Assigned IP address (if any) */
|
||||
framedIpAddress?: string;
|
||||
/** Called station ID (usually BSSID for wireless) */
|
||||
calledStationId?: string;
|
||||
/** Calling station ID (usually client MAC) */
|
||||
callingStationId?: string;
|
||||
/** Session start time */
|
||||
startTime: number;
|
||||
/** Session end time (0 if active) */
|
||||
endTime: number;
|
||||
/** Last update time (interim accounting) */
|
||||
lastUpdateTime: number;
|
||||
/** Session status */
|
||||
status: 'active' | 'stopped' | 'terminated';
|
||||
/** Termination cause (if stopped) */
|
||||
terminateCause?: string;
|
||||
/** Input octets (bytes received by NAS from client) */
|
||||
inputOctets: number;
|
||||
/** Output octets (bytes sent by NAS to client) */
|
||||
outputOctets: number;
|
||||
/** Input packets */
|
||||
inputPackets: number;
|
||||
/** Output packets */
|
||||
outputPackets: number;
|
||||
/** Session duration in seconds */
|
||||
sessionTime: number;
|
||||
/** Service type */
|
||||
serviceType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounting summary for a time period
|
||||
*/
|
||||
export interface IAccountingSummary {
|
||||
/** Time period start */
|
||||
periodStart: number;
|
||||
/** Time period end */
|
||||
periodEnd: number;
|
||||
/** Total sessions */
|
||||
totalSessions: number;
|
||||
/** Active sessions */
|
||||
activeSessions: number;
|
||||
/** Total input bytes */
|
||||
totalInputBytes: number;
|
||||
/** Total output bytes */
|
||||
totalOutputBytes: number;
|
||||
/** Total session time (seconds) */
|
||||
totalSessionTime: number;
|
||||
/** Average session duration (seconds) */
|
||||
averageSessionDuration: number;
|
||||
/** Unique users/devices */
|
||||
uniqueUsers: number;
|
||||
/** Sessions by VLAN */
|
||||
sessionsByVlan: Record<number, number>;
|
||||
/** Top users by traffic */
|
||||
topUsersByTraffic: Array<{ username: string; totalBytes: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounting manager configuration
|
||||
*/
|
||||
export interface IAccountingManagerConfig {
|
||||
/** Storage key prefix */
|
||||
storagePrefix?: string;
|
||||
/** Session retention period in days (default: 30) */
|
||||
retentionDays?: number;
|
||||
/** Enable detailed session logging */
|
||||
detailedLogging?: boolean;
|
||||
/** Maximum active sessions to track in memory */
|
||||
maxActiveSessions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages RADIUS accounting data including:
|
||||
* - Session tracking (start/stop/interim)
|
||||
* - Data usage tracking (bytes in/out)
|
||||
* - Session history and retention
|
||||
* - Billing reports and summaries
|
||||
*/
|
||||
export class AccountingManager {
|
||||
private activeSessions: Map<string, IAccountingSession> = new Map();
|
||||
private config: Required<IAccountingManagerConfig>;
|
||||
private storageManager?: StorageManager;
|
||||
|
||||
// Counters for statistics
|
||||
private stats = {
|
||||
totalSessionsStarted: 0,
|
||||
totalSessionsStopped: 0,
|
||||
totalInputBytes: 0,
|
||||
totalOutputBytes: 0,
|
||||
interimUpdatesReceived: 0,
|
||||
};
|
||||
|
||||
constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) {
|
||||
this.config = {
|
||||
storagePrefix: config?.storagePrefix ?? '/radius/accounting',
|
||||
retentionDays: config?.retentionDays ?? 30,
|
||||
detailedLogging: config?.detailedLogging ?? false,
|
||||
maxActiveSessions: config?.maxActiveSessions ?? 10000,
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the accounting manager
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.storageManager) {
|
||||
await this.loadActiveSessions();
|
||||
}
|
||||
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting start request
|
||||
*/
|
||||
async handleAccountingStart(data: {
|
||||
sessionId: string;
|
||||
username: string;
|
||||
macAddress?: string;
|
||||
nasIpAddress: string;
|
||||
nasPort?: number;
|
||||
nasPortType?: string;
|
||||
nasIdentifier?: string;
|
||||
vlanId?: number;
|
||||
framedIpAddress?: string;
|
||||
calledStationId?: string;
|
||||
callingStationId?: string;
|
||||
serviceType?: string;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
const session: IAccountingSession = {
|
||||
sessionId: data.sessionId,
|
||||
username: data.username,
|
||||
macAddress: data.macAddress,
|
||||
nasIpAddress: data.nasIpAddress,
|
||||
nasPort: data.nasPort,
|
||||
nasPortType: data.nasPortType,
|
||||
nasIdentifier: data.nasIdentifier,
|
||||
vlanId: data.vlanId,
|
||||
framedIpAddress: data.framedIpAddress,
|
||||
calledStationId: data.calledStationId,
|
||||
callingStationId: data.callingStationId,
|
||||
serviceType: data.serviceType,
|
||||
startTime: now,
|
||||
endTime: 0,
|
||||
lastUpdateTime: now,
|
||||
status: 'active',
|
||||
inputOctets: 0,
|
||||
outputOctets: 0,
|
||||
inputPackets: 0,
|
||||
outputPackets: 0,
|
||||
sessionTime: 0,
|
||||
};
|
||||
|
||||
// Check if we're at capacity
|
||||
if (this.activeSessions.size >= this.config.maxActiveSessions) {
|
||||
// Remove oldest session
|
||||
const oldest = this.findOldestSession();
|
||||
if (oldest) {
|
||||
await this.evictSession(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
this.activeSessions.set(data.sessionId, session);
|
||||
this.stats.totalSessionsStarted++;
|
||||
|
||||
if (this.config.detailedLogging) {
|
||||
logger.log('info', `Accounting Start: session=${data.sessionId}, user=${data.username}, NAS=${data.nasIpAddress}`);
|
||||
}
|
||||
|
||||
// Persist session
|
||||
if (this.storageManager) {
|
||||
await this.persistSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting interim update request
|
||||
*/
|
||||
async handleAccountingUpdate(data: {
|
||||
sessionId: string;
|
||||
inputOctets?: number;
|
||||
outputOctets?: number;
|
||||
inputPackets?: number;
|
||||
outputPackets?: number;
|
||||
sessionTime?: number;
|
||||
}): Promise<void> {
|
||||
const session = this.activeSessions.get(data.sessionId);
|
||||
|
||||
if (!session) {
|
||||
logger.log('warn', `Interim update for unknown session: ${data.sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update session metrics
|
||||
if (data.inputOctets !== undefined) {
|
||||
session.inputOctets = data.inputOctets;
|
||||
}
|
||||
if (data.outputOctets !== undefined) {
|
||||
session.outputOctets = data.outputOctets;
|
||||
}
|
||||
if (data.inputPackets !== undefined) {
|
||||
session.inputPackets = data.inputPackets;
|
||||
}
|
||||
if (data.outputPackets !== undefined) {
|
||||
session.outputPackets = data.outputPackets;
|
||||
}
|
||||
if (data.sessionTime !== undefined) {
|
||||
session.sessionTime = data.sessionTime;
|
||||
}
|
||||
|
||||
session.lastUpdateTime = Date.now();
|
||||
this.stats.interimUpdatesReceived++;
|
||||
|
||||
if (this.config.detailedLogging) {
|
||||
logger.log('debug', `Accounting Interim: session=${data.sessionId}, in=${data.inputOctets}, out=${data.outputOctets}`);
|
||||
}
|
||||
|
||||
// Update persisted session
|
||||
if (this.storageManager) {
|
||||
await this.persistSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting stop request
|
||||
*/
|
||||
async handleAccountingStop(data: {
|
||||
sessionId: string;
|
||||
terminateCause?: string;
|
||||
inputOctets?: number;
|
||||
outputOctets?: number;
|
||||
inputPackets?: number;
|
||||
outputPackets?: number;
|
||||
sessionTime?: number;
|
||||
}): Promise<void> {
|
||||
const session = this.activeSessions.get(data.sessionId);
|
||||
|
||||
if (!session) {
|
||||
logger.log('warn', `Stop for unknown session: ${data.sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update final metrics
|
||||
if (data.inputOctets !== undefined) {
|
||||
session.inputOctets = data.inputOctets;
|
||||
}
|
||||
if (data.outputOctets !== undefined) {
|
||||
session.outputOctets = data.outputOctets;
|
||||
}
|
||||
if (data.inputPackets !== undefined) {
|
||||
session.inputPackets = data.inputPackets;
|
||||
}
|
||||
if (data.outputPackets !== undefined) {
|
||||
session.outputPackets = data.outputPackets;
|
||||
}
|
||||
if (data.sessionTime !== undefined) {
|
||||
session.sessionTime = data.sessionTime;
|
||||
}
|
||||
|
||||
session.endTime = Date.now();
|
||||
session.lastUpdateTime = session.endTime;
|
||||
session.status = 'stopped';
|
||||
session.terminateCause = data.terminateCause;
|
||||
|
||||
// Update global stats
|
||||
this.stats.totalSessionsStopped++;
|
||||
this.stats.totalInputBytes += session.inputOctets;
|
||||
this.stats.totalOutputBytes += session.outputOctets;
|
||||
|
||||
if (this.config.detailedLogging) {
|
||||
logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`);
|
||||
}
|
||||
|
||||
// Archive the session
|
||||
if (this.storageManager) {
|
||||
await this.archiveSession(session);
|
||||
}
|
||||
|
||||
// Remove from active sessions
|
||||
this.activeSessions.delete(data.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active session by ID
|
||||
*/
|
||||
getSession(sessionId: string): IAccountingSession | undefined {
|
||||
return this.activeSessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getActiveSessions(): IAccountingSession[] {
|
||||
return Array.from(this.activeSessions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions by username
|
||||
*/
|
||||
getSessionsByUsername(username: string): IAccountingSession[] {
|
||||
return Array.from(this.activeSessions.values()).filter(s => s.username === username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions by NAS IP
|
||||
*/
|
||||
getSessionsByNas(nasIpAddress: string): IAccountingSession[] {
|
||||
return Array.from(this.activeSessions.values()).filter(s => s.nasIpAddress === nasIpAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions by VLAN
|
||||
*/
|
||||
getSessionsByVlan(vlanId: number): IAccountingSession[] {
|
||||
return Array.from(this.activeSessions.values()).filter(s => s.vlanId === vlanId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounting summary for a time period
|
||||
*/
|
||||
async getSummary(startTime: number, endTime: number): Promise<IAccountingSummary> {
|
||||
// Get archived sessions for the time period
|
||||
const archivedSessions = await this.getArchivedSessions(startTime, endTime);
|
||||
|
||||
// Combine with active sessions that started within the period
|
||||
const activeSessions = Array.from(this.activeSessions.values()).filter(
|
||||
s => s.startTime >= startTime && s.startTime <= endTime
|
||||
);
|
||||
|
||||
const allSessions = [...archivedSessions, ...activeSessions];
|
||||
|
||||
// Calculate summary
|
||||
let totalInputBytes = 0;
|
||||
let totalOutputBytes = 0;
|
||||
let totalSessionTime = 0;
|
||||
const uniqueUsers = new Set<string>();
|
||||
const sessionsByVlan: Record<number, number> = {};
|
||||
const userTraffic: Record<string, number> = {};
|
||||
|
||||
for (const session of allSessions) {
|
||||
totalInputBytes += session.inputOctets;
|
||||
totalOutputBytes += session.outputOctets;
|
||||
totalSessionTime += session.sessionTime;
|
||||
uniqueUsers.add(session.username);
|
||||
|
||||
if (session.vlanId !== undefined) {
|
||||
sessionsByVlan[session.vlanId] = (sessionsByVlan[session.vlanId] || 0) + 1;
|
||||
}
|
||||
|
||||
const userBytes = session.inputOctets + session.outputOctets;
|
||||
userTraffic[session.username] = (userTraffic[session.username] || 0) + userBytes;
|
||||
}
|
||||
|
||||
// Top users by traffic
|
||||
const topUsersByTraffic = Object.entries(userTraffic)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([username, totalBytes]) => ({ username, totalBytes }));
|
||||
|
||||
return {
|
||||
periodStart: startTime,
|
||||
periodEnd: endTime,
|
||||
totalSessions: allSessions.length,
|
||||
activeSessions: activeSessions.length,
|
||||
totalInputBytes,
|
||||
totalOutputBytes,
|
||||
totalSessionTime,
|
||||
averageSessionDuration: allSessions.length > 0 ? totalSessionTime / allSessions.length : 0,
|
||||
uniqueUsers: uniqueUsers.size,
|
||||
sessionsByVlan,
|
||||
topUsersByTraffic,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
getStats(): {
|
||||
activeSessions: number;
|
||||
totalSessionsStarted: number;
|
||||
totalSessionsStopped: number;
|
||||
totalInputBytes: number;
|
||||
totalOutputBytes: number;
|
||||
interimUpdatesReceived: number;
|
||||
} {
|
||||
return {
|
||||
activeSessions: this.activeSessions.size,
|
||||
...this.stats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a session (admin action)
|
||||
*/
|
||||
async disconnectSession(sessionId: string, reason: string = 'AdminReset'): Promise<boolean> {
|
||||
const session = this.activeSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.handleAccountingStop({
|
||||
sessionId,
|
||||
terminateCause: reason,
|
||||
sessionTime: Math.floor((Date.now() - session.startTime) / 1000),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old archived sessions based on retention policy
|
||||
*/
|
||||
async cleanupOldSessions(): Promise<number> {
|
||||
if (!this.storageManager) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
||||
let deletedCount = 0;
|
||||
|
||||
try {
|
||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (session && session.endTime > 0 && session.endTime < cutoffTime) {
|
||||
await this.storageManager.delete(key);
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to cleanup old sessions: ${error.message}`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the oldest active session
|
||||
*/
|
||||
private findOldestSession(): string | null {
|
||||
let oldestTime = Infinity;
|
||||
let oldestSessionId: string | null = null;
|
||||
|
||||
for (const [sessionId, session] of this.activeSessions) {
|
||||
if (session.lastUpdateTime < oldestTime) {
|
||||
oldestTime = session.lastUpdateTime;
|
||||
oldestSessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
return oldestSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict a session from memory
|
||||
*/
|
||||
private async evictSession(sessionId: string): Promise<void> {
|
||||
const session = this.activeSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.status = 'terminated';
|
||||
session.terminateCause = 'SessionEvicted';
|
||||
session.endTime = Date.now();
|
||||
|
||||
if (this.storageManager) {
|
||||
await this.archiveSession(session);
|
||||
}
|
||||
|
||||
this.activeSessions.delete(sessionId);
|
||||
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load active sessions from storage
|
||||
*/
|
||||
private async loadActiveSessions(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`);
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (session && session.status === 'active') {
|
||||
this.activeSessions.set(session.sessionId, session);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load active sessions: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a session to storage
|
||||
*/
|
||||
private async persistSession(session: IAccountingSession): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
||||
try {
|
||||
await this.storageManager.setJSON(key, session);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a completed session
|
||||
*/
|
||||
private async archiveSession(session: IAccountingSession): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove from active
|
||||
const activeKey = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
||||
await this.storageManager.delete(activeKey);
|
||||
|
||||
// Add to archive with date-based path
|
||||
const date = new Date(session.endTime);
|
||||
const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`;
|
||||
await this.storageManager.setJSON(archiveKey, session);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archived sessions for a time period
|
||||
*/
|
||||
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
|
||||
if (!this.storageManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions: IAccountingSession[] = [];
|
||||
|
||||
try {
|
||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (
|
||||
session &&
|
||||
session.endTime > 0 &&
|
||||
session.startTime <= endTime &&
|
||||
session.endTime >= startTime
|
||||
) {
|
||||
sessions.push(session);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to get archived sessions: ${error.message}`);
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
}
|
||||
532
ts/radius/classes.radius.server.ts
Normal file
532
ts/radius/classes.radius.server.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
|
||||
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
|
||||
|
||||
/**
|
||||
* RADIUS client (NAS) configuration
|
||||
*/
|
||||
export interface IRadiusClient {
|
||||
/** Client name for identification */
|
||||
name: string;
|
||||
/** IP address or CIDR range */
|
||||
ipRange: string;
|
||||
/** Shared secret for this client */
|
||||
secret: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Whether this client is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS server configuration
|
||||
*/
|
||||
export interface IRadiusServerConfig {
|
||||
/** Authentication port (default: 1812) */
|
||||
authPort?: number;
|
||||
/** Accounting port (default: 1813) */
|
||||
acctPort?: number;
|
||||
/** Bind address (default: 0.0.0.0) */
|
||||
bindAddress?: string;
|
||||
/** NAS clients configuration */
|
||||
clients: IRadiusClient[];
|
||||
/** VLAN assignment configuration */
|
||||
vlanAssignment?: IVlanManagerConfig & {
|
||||
/** Static MAC to VLAN mappings */
|
||||
mappings?: Array<Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>>;
|
||||
};
|
||||
/** Accounting configuration */
|
||||
accounting?: IAccountingManagerConfig & {
|
||||
/** Whether accounting is enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS authentication result
|
||||
*/
|
||||
export interface IRadiusAuthResult {
|
||||
/** Whether authentication was successful */
|
||||
success: boolean;
|
||||
/** Reject reason (if not successful) */
|
||||
rejectReason?: string;
|
||||
/** Reply message to send to client */
|
||||
replyMessage?: string;
|
||||
/** Session timeout in seconds */
|
||||
sessionTimeout?: number;
|
||||
/** Idle timeout in seconds */
|
||||
idleTimeout?: number;
|
||||
/** VLAN to assign */
|
||||
vlanId?: number;
|
||||
/** Framed IP address to assign */
|
||||
framedIpAddress?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication request data from RADIUS
|
||||
*/
|
||||
export interface IAuthRequestData {
|
||||
username: string;
|
||||
password?: string;
|
||||
nasIpAddress: string;
|
||||
nasPort?: number;
|
||||
nasPortType?: string;
|
||||
nasIdentifier?: string;
|
||||
calledStationId?: string;
|
||||
callingStationId?: string;
|
||||
serviceType?: string;
|
||||
framedMtu?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS Server wrapper that provides:
|
||||
* - MAC Authentication Bypass (MAB) for network devices
|
||||
* - VLAN assignment based on MAC address
|
||||
* - Accounting for session tracking and billing
|
||||
* - Integration with SmartProxy routing
|
||||
*/
|
||||
export class RadiusServer {
|
||||
private radiusServer?: plugins.smartradius.RadiusServer;
|
||||
private vlanManager: VlanManager;
|
||||
private accountingManager: AccountingManager;
|
||||
private config: IRadiusServerConfig;
|
||||
private storageManager?: StorageManager;
|
||||
private clientSecrets: Map<string, string> = new Map();
|
||||
private running: boolean = false;
|
||||
|
||||
// Statistics
|
||||
private stats = {
|
||||
authRequests: 0,
|
||||
authAccepts: 0,
|
||||
authRejects: 0,
|
||||
accountingRequests: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
constructor(config: IRadiusServerConfig, storageManager?: StorageManager) {
|
||||
this.config = {
|
||||
authPort: config.authPort ?? 1812,
|
||||
acctPort: config.acctPort ?? 1813,
|
||||
bindAddress: config.bindAddress ?? '0.0.0.0',
|
||||
...config,
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
|
||||
// Initialize VLAN manager
|
||||
this.vlanManager = new VlanManager(config.vlanAssignment, storageManager);
|
||||
|
||||
// Initialize accounting manager
|
||||
this.accountingManager = new AccountingManager(config.accounting, storageManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the RADIUS server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.log('warn', 'RADIUS server is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', `Starting RADIUS server on ${this.config.bindAddress}:${this.config.authPort} (auth) and ${this.config.acctPort} (acct)`);
|
||||
|
||||
// Initialize managers
|
||||
await this.vlanManager.initialize();
|
||||
await this.accountingManager.initialize();
|
||||
|
||||
// Import static VLAN mappings if provided
|
||||
if (this.config.vlanAssignment?.mappings) {
|
||||
await this.vlanManager.importMappings(this.config.vlanAssignment.mappings);
|
||||
}
|
||||
|
||||
// Build client secrets map
|
||||
this.buildClientSecretsMap();
|
||||
|
||||
// Create the RADIUS server
|
||||
this.radiusServer = new plugins.smartradius.RadiusServer({
|
||||
authPort: this.config.authPort,
|
||||
acctPort: this.config.acctPort,
|
||||
bindAddress: this.config.bindAddress,
|
||||
defaultSecret: this.getDefaultSecret(),
|
||||
authenticationHandler: this.handleAuthentication.bind(this),
|
||||
accountingHandler: this.handleAccounting.bind(this),
|
||||
});
|
||||
|
||||
// Configure per-client secrets
|
||||
for (const [ip, secret] of this.clientSecrets) {
|
||||
this.radiusServer.setClientSecret(ip, secret);
|
||||
}
|
||||
|
||||
// Start the server
|
||||
await this.radiusServer.start();
|
||||
|
||||
this.running = true;
|
||||
this.stats.startTime = Date.now();
|
||||
|
||||
logger.log('info', `RADIUS server started with ${this.config.clients.length} configured clients`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the RADIUS server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', 'Stopping RADIUS server...');
|
||||
|
||||
if (this.radiusServer) {
|
||||
await this.radiusServer.stop();
|
||||
this.radiusServer = undefined;
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
logger.log('info', 'RADIUS server stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication request
|
||||
*/
|
||||
private async handleAuthentication(request: any): Promise<any> {
|
||||
this.stats.authRequests++;
|
||||
|
||||
const authData: IAuthRequestData = {
|
||||
username: request.attributes?.UserName || '',
|
||||
password: request.attributes?.UserPassword,
|
||||
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
|
||||
nasPort: request.attributes?.NasPort,
|
||||
nasPortType: request.attributes?.NasPortType,
|
||||
nasIdentifier: request.attributes?.NasIdentifier,
|
||||
calledStationId: request.attributes?.CalledStationId,
|
||||
callingStationId: request.attributes?.CallingStationId,
|
||||
serviceType: request.attributes?.ServiceType,
|
||||
};
|
||||
|
||||
logger.log('debug', `RADIUS Auth Request: user=${authData.username}, NAS=${authData.nasIpAddress}`);
|
||||
|
||||
// Perform MAC Authentication Bypass (MAB)
|
||||
// In MAB, the username is typically the MAC address
|
||||
const result = await this.performMabAuthentication(authData);
|
||||
|
||||
if (result.success) {
|
||||
this.stats.authAccepts++;
|
||||
logger.log('info', `RADIUS Auth Accept: user=${authData.username}, VLAN=${result.vlanId}`);
|
||||
|
||||
// Build response with VLAN attributes
|
||||
const response: any = {
|
||||
code: plugins.smartradius.ERadiusCode.AccessAccept,
|
||||
replyMessage: result.replyMessage,
|
||||
};
|
||||
|
||||
// Add VLAN attributes if assigned
|
||||
if (result.vlanId !== undefined) {
|
||||
response.tunnelType = 13; // VLAN
|
||||
response.tunnelMediumType = 6; // IEEE 802
|
||||
response.tunnelPrivateGroupId = String(result.vlanId);
|
||||
}
|
||||
|
||||
// Add session timeout if specified
|
||||
if (result.sessionTimeout) {
|
||||
response.sessionTimeout = result.sessionTimeout;
|
||||
}
|
||||
|
||||
// Add idle timeout if specified
|
||||
if (result.idleTimeout) {
|
||||
response.idleTimeout = result.idleTimeout;
|
||||
}
|
||||
|
||||
// Add framed IP if specified
|
||||
if (result.framedIpAddress) {
|
||||
response.framedIpAddress = result.framedIpAddress;
|
||||
}
|
||||
|
||||
return response;
|
||||
} else {
|
||||
this.stats.authRejects++;
|
||||
logger.log('warn', `RADIUS Auth Reject: user=${authData.username}, reason=${result.rejectReason}`);
|
||||
|
||||
return {
|
||||
code: plugins.smartradius.ERadiusCode.AccessReject,
|
||||
replyMessage: result.rejectReason || 'Access Denied',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting request
|
||||
*/
|
||||
private async handleAccounting(request: any): Promise<any> {
|
||||
this.stats.accountingRequests++;
|
||||
|
||||
if (!this.config.accounting?.enabled) {
|
||||
// Still respond even if not tracking
|
||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||
}
|
||||
|
||||
const statusType = request.attributes?.AcctStatusType;
|
||||
const sessionId = request.attributes?.AcctSessionId || '';
|
||||
|
||||
const accountingData = {
|
||||
sessionId,
|
||||
username: request.attributes?.UserName || '',
|
||||
macAddress: request.attributes?.CallingStationId,
|
||||
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
|
||||
nasPort: request.attributes?.NasPort,
|
||||
nasPortType: request.attributes?.NasPortType,
|
||||
nasIdentifier: request.attributes?.NasIdentifier,
|
||||
calledStationId: request.attributes?.CalledStationId,
|
||||
callingStationId: request.attributes?.CallingStationId,
|
||||
inputOctets: request.attributes?.AcctInputOctets,
|
||||
outputOctets: request.attributes?.AcctOutputOctets,
|
||||
inputPackets: request.attributes?.AcctInputPackets,
|
||||
outputPackets: request.attributes?.AcctOutputPackets,
|
||||
sessionTime: request.attributes?.AcctSessionTime,
|
||||
terminateCause: request.attributes?.AcctTerminateCause,
|
||||
serviceType: request.attributes?.ServiceType,
|
||||
};
|
||||
|
||||
try {
|
||||
switch (statusType) {
|
||||
case plugins.smartradius.EAcctStatusType.Start:
|
||||
logger.log('debug', `RADIUS Acct Start: session=${sessionId}, user=${accountingData.username}`);
|
||||
await this.accountingManager.handleAccountingStart(accountingData);
|
||||
break;
|
||||
|
||||
case plugins.smartradius.EAcctStatusType.Stop:
|
||||
logger.log('debug', `RADIUS Acct Stop: session=${sessionId}`);
|
||||
await this.accountingManager.handleAccountingStop(accountingData);
|
||||
break;
|
||||
|
||||
case plugins.smartradius.EAcctStatusType.InterimUpdate:
|
||||
logger.log('debug', `RADIUS Acct Interim: session=${sessionId}`);
|
||||
await this.accountingManager.handleAccountingUpdate(accountingData);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `RADIUS accounting error: ${error.message}`);
|
||||
}
|
||||
|
||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform MAC Authentication Bypass
|
||||
*/
|
||||
private async performMabAuthentication(data: IAuthRequestData): Promise<IRadiusAuthResult> {
|
||||
// Extract MAC address from username or CallingStationId
|
||||
const macAddress = this.extractMacAddress(data);
|
||||
|
||||
if (!macAddress) {
|
||||
return {
|
||||
success: false,
|
||||
rejectReason: 'No MAC address found',
|
||||
};
|
||||
}
|
||||
|
||||
// Look up VLAN assignment
|
||||
const vlanResult = this.vlanManager.assignVlan(macAddress);
|
||||
|
||||
if (!vlanResult.assigned) {
|
||||
return {
|
||||
success: false,
|
||||
rejectReason: 'Unknown MAC address',
|
||||
};
|
||||
}
|
||||
|
||||
// Build successful result
|
||||
const result: IRadiusAuthResult = {
|
||||
success: true,
|
||||
vlanId: vlanResult.vlan,
|
||||
replyMessage: vlanResult.isDefault
|
||||
? `Assigned to default VLAN ${vlanResult.vlan}`
|
||||
: `Assigned to VLAN ${vlanResult.vlan}`,
|
||||
};
|
||||
|
||||
// Apply any additional settings from the matched rule
|
||||
if (vlanResult.matchedRule) {
|
||||
// Future: Add session timeout, idle timeout, etc. from rule
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MAC address from authentication data
|
||||
*/
|
||||
private extractMacAddress(data: IAuthRequestData): string | null {
|
||||
// Try CallingStationId first (most common for MAB)
|
||||
if (data.callingStationId) {
|
||||
return this.normalizeMac(data.callingStationId);
|
||||
}
|
||||
|
||||
// Try username (often MAC address in MAB)
|
||||
if (data.username && this.looksLikeMac(data.username)) {
|
||||
return this.normalizeMac(data.username);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a MAC address
|
||||
*/
|
||||
private looksLikeMac(value: string): boolean {
|
||||
// Remove common separators and check length
|
||||
const cleaned = value.replace(/[-:. ]/g, '');
|
||||
return /^[0-9a-fA-F]{12}$/.test(cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize MAC address format
|
||||
*/
|
||||
private normalizeMac(mac: string): string {
|
||||
return this.vlanManager.normalizeMac(mac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build client secrets map from configuration
|
||||
*/
|
||||
private buildClientSecretsMap(): void {
|
||||
this.clientSecrets.clear();
|
||||
|
||||
for (const client of this.config.clients) {
|
||||
if (!client.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle CIDR ranges
|
||||
if (client.ipRange.includes('/')) {
|
||||
// For CIDR ranges, we'll use the network address as key
|
||||
// In practice, smartradius may handle this differently
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.clientSecrets.set(network, client.secret);
|
||||
} else {
|
||||
this.clientSecrets.set(client.ipRange, client.secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default secret for unknown clients
|
||||
*/
|
||||
private getDefaultSecret(): string {
|
||||
// Use first enabled client's secret as default, or a random one
|
||||
for (const client of this.config.clients) {
|
||||
if (client.enabled) {
|
||||
return client.secret;
|
||||
}
|
||||
}
|
||||
return plugins.crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a RADIUS client
|
||||
*/
|
||||
async addClient(client: IRadiusClient): Promise<void> {
|
||||
// Check if client already exists
|
||||
const existingIndex = this.config.clients.findIndex(c => c.name === client.name);
|
||||
if (existingIndex >= 0) {
|
||||
this.config.clients[existingIndex] = client;
|
||||
} else {
|
||||
this.config.clients.push(client);
|
||||
}
|
||||
|
||||
// Update client secrets if running
|
||||
if (this.running && this.radiusServer && client.enabled) {
|
||||
if (client.ipRange.includes('/')) {
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.radiusServer.setClientSecret(network, client.secret);
|
||||
this.clientSecrets.set(network, client.secret);
|
||||
} else {
|
||||
this.radiusServer.setClientSecret(client.ipRange, client.secret);
|
||||
this.clientSecrets.set(client.ipRange, client.secret);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `RADIUS client ${client.enabled ? 'added' : 'disabled'}: ${client.name} (${client.ipRange})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a RADIUS client
|
||||
*/
|
||||
removeClient(name: string): boolean {
|
||||
const index = this.config.clients.findIndex(c => c.name === name);
|
||||
if (index >= 0) {
|
||||
const client = this.config.clients[index];
|
||||
this.config.clients.splice(index, 1);
|
||||
|
||||
// Remove from secrets map
|
||||
if (client.ipRange.includes('/')) {
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.clientSecrets.delete(network);
|
||||
} else {
|
||||
this.clientSecrets.delete(client.ipRange);
|
||||
}
|
||||
|
||||
logger.log('info', `RADIUS client removed: ${name}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured clients
|
||||
*/
|
||||
getClients(): IRadiusClient[] {
|
||||
return [...this.config.clients];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VLAN manager for direct access to VLAN operations
|
||||
*/
|
||||
getVlanManager(): VlanManager {
|
||||
return this.vlanManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounting manager for direct access to accounting operations
|
||||
*/
|
||||
getAccountingManager(): AccountingManager {
|
||||
return this.accountingManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server statistics
|
||||
*/
|
||||
getStats(): {
|
||||
running: boolean;
|
||||
uptime: number;
|
||||
authRequests: number;
|
||||
authAccepts: number;
|
||||
authRejects: number;
|
||||
accountingRequests: number;
|
||||
activeSessions: number;
|
||||
vlanMappings: number;
|
||||
clients: number;
|
||||
} {
|
||||
return {
|
||||
running: this.running,
|
||||
uptime: this.running ? Date.now() - this.stats.startTime : 0,
|
||||
authRequests: this.stats.authRequests,
|
||||
authAccepts: this.stats.authAccepts,
|
||||
authRejects: this.stats.authRejects,
|
||||
accountingRequests: this.stats.accountingRequests,
|
||||
activeSessions: this.accountingManager.getStats().activeSessions,
|
||||
vlanMappings: this.vlanManager.getStats().totalMappings,
|
||||
clients: this.config.clients.filter(c => c.enabled).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
363
ts/radius/classes.vlan.manager.ts
Normal file
363
ts/radius/classes.vlan.manager.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
|
||||
/**
|
||||
* MAC address to VLAN mapping
|
||||
*/
|
||||
export interface IMacVlanMapping {
|
||||
/** MAC address (full) or OUI pattern (e.g., "00:11:22" for vendor prefix) */
|
||||
mac: string;
|
||||
/** VLAN ID to assign */
|
||||
vlan: number;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Whether this mapping is enabled */
|
||||
enabled: boolean;
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
/** Last update timestamp */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* VLAN assignment result
|
||||
*/
|
||||
export interface IVlanAssignmentResult {
|
||||
/** Whether a VLAN was successfully assigned */
|
||||
assigned: boolean;
|
||||
/** The assigned VLAN ID (or default if not matched) */
|
||||
vlan: number;
|
||||
/** The matching rule (if any) */
|
||||
matchedRule?: IMacVlanMapping;
|
||||
/** Whether default VLAN was used */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* VlanManager configuration
|
||||
*/
|
||||
export interface IVlanManagerConfig {
|
||||
/** Default VLAN for unknown MACs */
|
||||
defaultVlan?: number;
|
||||
/** Whether to allow unknown MACs (assign default VLAN) or reject */
|
||||
allowUnknownMacs?: boolean;
|
||||
/** Storage key prefix for persistence */
|
||||
storagePrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages MAC address to VLAN mappings with support for:
|
||||
* - Exact MAC address matching
|
||||
* - OUI (vendor prefix) pattern matching
|
||||
* - Wildcard patterns
|
||||
* - Default VLAN for unknown devices
|
||||
*/
|
||||
export class VlanManager {
|
||||
private mappings: Map<string, IMacVlanMapping> = new Map();
|
||||
private config: Required<IVlanManagerConfig>;
|
||||
private storageManager?: StorageManager;
|
||||
|
||||
// Cache for normalized MAC lookups
|
||||
private normalizedMacCache: Map<string, string> = new Map();
|
||||
|
||||
constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) {
|
||||
this.config = {
|
||||
defaultVlan: config?.defaultVlan ?? 1,
|
||||
allowUnknownMacs: config?.allowUnknownMacs ?? true,
|
||||
storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings',
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VLAN manager and load persisted mappings
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.storageManager) {
|
||||
await this.loadMappings();
|
||||
}
|
||||
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a MAC address to lowercase with colons
|
||||
* Accepts formats: 00:11:22:33:44:55, 00-11-22-33-44-55, 001122334455
|
||||
*/
|
||||
normalizeMac(mac: string): string {
|
||||
// Check cache first
|
||||
const cached = this.normalizedMacCache.get(mac);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Remove all separators and convert to lowercase
|
||||
const cleaned = mac.toLowerCase().replace(/[-:]/g, '');
|
||||
|
||||
// Format with colons
|
||||
const normalized = cleaned.match(/.{1,2}/g)?.join(':') || mac.toLowerCase();
|
||||
|
||||
// Cache the result
|
||||
this.normalizedMacCache.set(mac, normalized);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MAC address matches a pattern
|
||||
* Supports:
|
||||
* - Exact match: "00:11:22:33:44:55"
|
||||
* - OUI match: "00:11:22" (matches any device with this vendor prefix)
|
||||
* - Wildcard: "*" (matches all)
|
||||
*/
|
||||
macMatchesPattern(mac: string, pattern: string): boolean {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
const normalizedPattern = this.normalizeMac(pattern);
|
||||
|
||||
// Wildcard matches all
|
||||
if (pattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (normalizedMac === normalizedPattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OUI/prefix match (pattern is shorter than full MAC)
|
||||
if (normalizedPattern.length < 17 && normalizedMac.startsWith(normalizedPattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a MAC to VLAN mapping
|
||||
*/
|
||||
async addMapping(mapping: Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>): Promise<IMacVlanMapping> {
|
||||
const normalizedMac = this.normalizeMac(mapping.mac);
|
||||
const now = Date.now();
|
||||
|
||||
const existingMapping = this.mappings.get(normalizedMac);
|
||||
const fullMapping: IMacVlanMapping = {
|
||||
...mapping,
|
||||
mac: normalizedMac,
|
||||
createdAt: existingMapping?.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.mappings.set(normalizedMac, fullMapping);
|
||||
|
||||
// Persist to storage
|
||||
if (this.storageManager) {
|
||||
await this.saveMappings();
|
||||
}
|
||||
|
||||
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
|
||||
return fullMapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a MAC to VLAN mapping
|
||||
*/
|
||||
async removeMapping(mac: string): Promise<boolean> {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
const removed = this.mappings.delete(normalizedMac);
|
||||
|
||||
if (removed && this.storageManager) {
|
||||
await this.saveMappings();
|
||||
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific mapping by MAC
|
||||
*/
|
||||
getMapping(mac: string): IMacVlanMapping | undefined {
|
||||
return this.mappings.get(this.normalizeMac(mac));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mappings
|
||||
*/
|
||||
getAllMappings(): IMacVlanMapping[] {
|
||||
return Array.from(this.mappings.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine VLAN assignment for a MAC address
|
||||
* Returns the most specific matching rule (exact > OUI > wildcard > default)
|
||||
*/
|
||||
assignVlan(mac: string): IVlanAssignmentResult {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
|
||||
// First, try exact match
|
||||
const exactMatch = this.mappings.get(normalizedMac);
|
||||
if (exactMatch && exactMatch.enabled) {
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: exactMatch.vlan,
|
||||
matchedRule: exactMatch,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Try OUI/prefix matches (sorted by specificity - longer patterns first)
|
||||
const patternMatches: IMacVlanMapping[] = [];
|
||||
for (const mapping of this.mappings.values()) {
|
||||
if (mapping.enabled && mapping.mac !== normalizedMac && this.macMatchesPattern(normalizedMac, mapping.mac)) {
|
||||
patternMatches.push(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by pattern length (most specific first)
|
||||
patternMatches.sort((a, b) => b.mac.length - a.mac.length);
|
||||
|
||||
if (patternMatches.length > 0) {
|
||||
const bestMatch = patternMatches[0];
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: bestMatch.vlan,
|
||||
matchedRule: bestMatch,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No match - use default VLAN if allowed
|
||||
if (this.config.allowUnknownMacs) {
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: this.config.defaultVlan,
|
||||
isDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown MAC and not allowed
|
||||
return {
|
||||
assigned: false,
|
||||
vlan: 0,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import mappings
|
||||
*/
|
||||
async importMappings(mappings: Array<Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>>): Promise<number> {
|
||||
let imported = 0;
|
||||
|
||||
for (const mapping of mappings) {
|
||||
await this.addMapping(mapping);
|
||||
imported++;
|
||||
}
|
||||
|
||||
logger.log('info', `Imported ${imported} VLAN mappings`);
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all mappings
|
||||
*/
|
||||
exportMappings(): IMacVlanMapping[] {
|
||||
return this.getAllMappings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<IVlanManagerConfig>): void {
|
||||
if (config.defaultVlan !== undefined) {
|
||||
this.config.defaultVlan = config.defaultVlan;
|
||||
}
|
||||
if (config.allowUnknownMacs !== undefined) {
|
||||
this.config.allowUnknownMacs = config.allowUnknownMacs;
|
||||
}
|
||||
logger.log('info', `VlanManager config updated: defaultVlan=${this.config.defaultVlan}, allowUnknown=${this.config.allowUnknownMacs}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): Required<IVlanManagerConfig> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
getStats(): {
|
||||
totalMappings: number;
|
||||
enabledMappings: number;
|
||||
exactMatches: number;
|
||||
ouiPatterns: number;
|
||||
wildcardPatterns: number;
|
||||
} {
|
||||
let exactMatches = 0;
|
||||
let ouiPatterns = 0;
|
||||
let wildcardPatterns = 0;
|
||||
let enabledMappings = 0;
|
||||
|
||||
for (const mapping of this.mappings.values()) {
|
||||
if (mapping.enabled) {
|
||||
enabledMappings++;
|
||||
}
|
||||
|
||||
if (mapping.mac === '*') {
|
||||
wildcardPatterns++;
|
||||
} else if (mapping.mac.length < 17) {
|
||||
// OUI patterns are shorter than full MAC (17 chars with colons)
|
||||
ouiPatterns++;
|
||||
} else {
|
||||
exactMatches++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalMappings: this.mappings.size,
|
||||
enabledMappings,
|
||||
exactMatches,
|
||||
ouiPatterns,
|
||||
wildcardPatterns,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mappings from storage
|
||||
*/
|
||||
private async loadMappings(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(this.config.storagePrefix);
|
||||
if (data && Array.isArray(data)) {
|
||||
for (const mapping of data) {
|
||||
this.mappings.set(this.normalizeMac(mapping.mac), mapping);
|
||||
}
|
||||
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save mappings to storage
|
||||
*/
|
||||
private async saveMappings(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mappings = Array.from(this.mappings.values());
|
||||
await this.storageManager.setJSON(this.config.storagePrefix, mappings);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
ts/radius/index.ts
Normal file
14
ts/radius/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* RADIUS module for DcRouter
|
||||
*
|
||||
* Provides:
|
||||
* - MAC Authentication Bypass (MAB) for network device authentication
|
||||
* - VLAN assignment based on MAC addresses
|
||||
* - OUI (vendor prefix) pattern matching for device categorization
|
||||
* - RADIUS accounting for session tracking and billing
|
||||
* - Integration with StorageManager for persistence
|
||||
*/
|
||||
|
||||
export * from './classes.radius.server.js';
|
||||
export * from './classes.vlan.manager.js';
|
||||
export * from './classes.accounting.manager.js';
|
||||
@@ -68,13 +68,13 @@ export class SmsService {
|
||||
recipients: [{ msisdn: toNumber }],
|
||||
};
|
||||
|
||||
const resp = await plugins.smartrequest.SmartRequestClient.create()
|
||||
const resp = await plugins.smartrequest.SmartRequest.create()
|
||||
.url('https://gatewayapi.com/rest/mtsms')
|
||||
.header('Authorization', `Basic ${Buffer.from(`${this.config.apiGatewayApiToken}:`).toString('base64')}`)
|
||||
.header('Content-Type', 'application/json')
|
||||
.json(payload)
|
||||
.post();
|
||||
const json = resp.body;
|
||||
const json = await resp.json();
|
||||
logger.log('info', `sent an sms to ${toNumber} with text '${messageText}'`, {
|
||||
eventType: 'sentSms',
|
||||
sms: {
|
||||
|
||||
174
ts_interfaces/readme.md
Normal file
174
ts_interfaces/readme.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# @serve.zone/dcrouter-interfaces
|
||||
|
||||
TypeScript interfaces and type definitions for the DCRouter OpsServer API. 📡
|
||||
|
||||
This module provides strongly-typed interfaces for communicating with the DCRouter OpsServer via TypedRequest. Use these interfaces for type-safe API interactions in your frontend applications or integration code.
|
||||
|
||||
## 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
|
||||
npm install @serve.zone/dcrouter-interfaces --save
|
||||
# or
|
||||
pnpm add @serve.zone/dcrouter-interfaces
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||
|
||||
// Use data interfaces for type definitions
|
||||
const identity: data.IIdentity = {
|
||||
jwt: 'your-jwt-token',
|
||||
userId: 'user-123',
|
||||
name: 'Admin User',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
role: 'admin'
|
||||
};
|
||||
|
||||
// Use request interfaces for API calls
|
||||
const statsRequest: requests.IReq_GetServerStatistics = {
|
||||
method: 'getServerStatistics',
|
||||
request: {
|
||||
identity,
|
||||
includeHistory: true,
|
||||
timeRange: '24h'
|
||||
},
|
||||
response: null // Will be populated by the response
|
||||
};
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
### Data Interfaces (`data`)
|
||||
|
||||
Core data types used throughout the DCRouter system:
|
||||
|
||||
#### `IIdentity`
|
||||
Authentication identity for API requests:
|
||||
```typescript
|
||||
interface IIdentity {
|
||||
jwt: string; // JWT token for authentication
|
||||
userId: string; // Unique user identifier
|
||||
name: string; // Display name
|
||||
expiresAt: number; // Token expiration timestamp
|
||||
role?: string; // User role (e.g., 'admin')
|
||||
type?: string; // Identity type
|
||||
}
|
||||
```
|
||||
|
||||
#### Statistics Interfaces
|
||||
- `IServerStats` - Overall server statistics
|
||||
- `IEmailStats` - Email throughput and delivery metrics
|
||||
- `IDnsStats` - DNS query statistics
|
||||
- `IRateLimitInfo` - Rate limiting status
|
||||
- `ISecurityMetrics` - Security event metrics
|
||||
- `IConnectionInfo` - Active connection details
|
||||
- `IQueueStatus` - Email queue status
|
||||
- `IHealthStatus` - System health information
|
||||
|
||||
### Request Interfaces (`requests`)
|
||||
|
||||
TypedRequest interfaces for the OpsServer API:
|
||||
|
||||
#### Statistics Requests
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetServerStatistics` | `getServerStatistics` | Get overall server stats |
|
||||
| `IReq_GetEmailStatistics` | `getEmailStatistics` | Get email throughput stats |
|
||||
| `IReq_GetDnsStatistics` | `getDnsStatistics` | Get DNS query stats |
|
||||
| `IReq_GetRateLimitStatus` | `getRateLimitStatus` | Check rate limit status |
|
||||
| `IReq_GetSecurityMetrics` | `getSecurityMetrics` | Get security event metrics |
|
||||
| `IReq_GetActiveConnections` | `getActiveConnections` | List active connections |
|
||||
| `IReq_GetQueueStatus` | `getQueueStatus` | Get email queue status |
|
||||
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
||||
|
||||
#### Admin Requests
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_AdminLogin` | `adminLogin` | Authenticate as admin |
|
||||
| `IReq_AdminLogout` | `adminLogout` | End admin session |
|
||||
| `IReq_VerifyIdentity` | `verifyIdentity` | Verify JWT token |
|
||||
|
||||
#### Configuration Requests
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetConfiguration` | `getConfiguration` | Get current config (read-only) |
|
||||
|
||||
#### Log Requests
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetLogs` | `getLogs` | Retrieve system logs |
|
||||
|
||||
#### RADIUS Requests
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetRadiusSessions` | `getRadiusSessions` | List RADIUS sessions |
|
||||
| `IReq_GetRadiusClients` | `getRadiusClients` | List RADIUS clients |
|
||||
|
||||
#### Email Operations
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetEmailQueues` | `getEmailQueues` | Get email queue details |
|
||||
| `IReq_RetryEmail` | `retryEmail` | Retry failed email |
|
||||
|
||||
## Example: Complete API Integration
|
||||
|
||||
```typescript
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||
|
||||
// Create typed request client
|
||||
const client = new typedrequest.TypedRequest<requests.IReq_AdminLogin>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'adminLogin'
|
||||
);
|
||||
|
||||
// Login to get identity
|
||||
const loginResponse = await client.fire({
|
||||
username: 'admin',
|
||||
password: 'your-password'
|
||||
});
|
||||
|
||||
const identity = loginResponse.identity;
|
||||
|
||||
// Now use identity for authenticated requests
|
||||
const statsClient = new typedrequest.TypedRequest<requests.IReq_GetServerStatistics>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getServerStatistics'
|
||||
);
|
||||
|
||||
const stats = await statsClient.fire({
|
||||
identity,
|
||||
includeHistory: true,
|
||||
timeRange: '24h'
|
||||
});
|
||||
|
||||
console.log('Server stats:', stats.stats);
|
||||
console.log('History:', stats.history);
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
// Get Configuration
|
||||
// Get Configuration (read-only)
|
||||
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetConfiguration
|
||||
@@ -16,20 +16,3 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
|
||||
section?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Update Configuration
|
||||
export interface IReq_UpdateConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateConfiguration
|
||||
> {
|
||||
method: 'updateConfiguration';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
section: string;
|
||||
config: any;
|
||||
};
|
||||
response: {
|
||||
updated: boolean;
|
||||
config: any;
|
||||
};
|
||||
}
|
||||
239
ts_interfaces/requests/email-ops.ts
Normal file
239
ts_interfaces/requests/email-ops.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
// ============================================================================
|
||||
// Email Queue Item Interface (matches backend IQueueItem)
|
||||
// ============================================================================
|
||||
export type TEmailQueueStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
|
||||
export interface IEmailQueueItem {
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bounce Record Interface (matches backend BounceRecord)
|
||||
// ============================================================================
|
||||
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<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetQueuedEmails
|
||||
> {
|
||||
method: 'getQueuedEmails';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
status?: TEmailQueueStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Sent Emails Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetSentEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSentEmails
|
||||
> {
|
||||
method: 'getSentEmails';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resend Failed Email Request
|
||||
// ============================================================================
|
||||
export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ResendEmail
|
||||
> {
|
||||
method: 'resendEmail';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
emailId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
newQueueId?: string;
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export * from './config.js';
|
||||
export * from './logs.js';
|
||||
export * from './stats.js';
|
||||
export * from './combined.stats.js';
|
||||
export * from './radius.js';
|
||||
export * from './email-ops.js';
|
||||
329
ts_interfaces/requests/radius.ts
Normal file
329
ts_interfaces/requests/radius.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
// ============================================================================
|
||||
// RADIUS Client Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all RADIUS clients (NAS devices)
|
||||
*/
|
||||
export interface IReq_GetRadiusClients extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRadiusClients
|
||||
> {
|
||||
method: 'getRadiusClients';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
clients: Array<{
|
||||
name: string;
|
||||
ipRange: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a RADIUS client
|
||||
*/
|
||||
export interface IReq_SetRadiusClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SetRadiusClient
|
||||
> {
|
||||
method: 'setRadiusClient';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
client: {
|
||||
name: string;
|
||||
ipRange: string;
|
||||
secret: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a RADIUS client
|
||||
*/
|
||||
export interface IReq_RemoveRadiusClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveRadiusClient
|
||||
> {
|
||||
method: 'removeRadiusClient';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
name: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VLAN Mapping Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all MAC-to-VLAN mappings
|
||||
*/
|
||||
export interface IReq_GetVlanMappings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetVlanMappings
|
||||
> {
|
||||
method: 'getVlanMappings';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
mappings: Array<{
|
||||
mac: string;
|
||||
vlan: number;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}>;
|
||||
config: {
|
||||
defaultVlan: number;
|
||||
allowUnknownMacs: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a VLAN mapping
|
||||
*/
|
||||
export interface IReq_SetVlanMapping extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SetVlanMapping
|
||||
> {
|
||||
method: 'setVlanMapping';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
mapping: {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
mapping?: {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a VLAN mapping
|
||||
*/
|
||||
export interface IReq_RemoveVlanMapping extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveVlanMapping
|
||||
> {
|
||||
method: 'removeVlanMapping';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
mac: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update VLAN configuration
|
||||
*/
|
||||
export interface IReq_UpdateVlanConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateVlanConfig
|
||||
> {
|
||||
method: 'updateVlanConfig';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
defaultVlan?: number;
|
||||
allowUnknownMacs?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
config: {
|
||||
defaultVlan: number;
|
||||
allowUnknownMacs: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test VLAN assignment for a MAC address
|
||||
*/
|
||||
export interface IReq_TestVlanAssignment extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_TestVlanAssignment
|
||||
> {
|
||||
method: 'testVlanAssignment';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
mac: string;
|
||||
};
|
||||
response: {
|
||||
assigned: boolean;
|
||||
vlan: number;
|
||||
isDefault: boolean;
|
||||
matchedRule?: {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Accounting / Session Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get active RADIUS sessions
|
||||
*/
|
||||
export interface IReq_GetRadiusSessions extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRadiusSessions
|
||||
> {
|
||||
method: 'getRadiusSessions';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
filter?: {
|
||||
username?: string;
|
||||
nasIpAddress?: string;
|
||||
vlanId?: number;
|
||||
};
|
||||
};
|
||||
response: {
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
username: string;
|
||||
macAddress?: string;
|
||||
nasIpAddress: string;
|
||||
nasIdentifier?: string;
|
||||
vlanId?: number;
|
||||
framedIpAddress?: string;
|
||||
startTime: number;
|
||||
lastUpdateTime: number;
|
||||
status: 'active' | 'stopped' | 'terminated';
|
||||
inputOctets: number;
|
||||
outputOctets: number;
|
||||
sessionTime: number;
|
||||
}>;
|
||||
totalCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a RADIUS session
|
||||
*/
|
||||
export interface IReq_DisconnectRadiusSession extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DisconnectRadiusSession
|
||||
> {
|
||||
method: 'disconnectRadiusSession';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
sessionId: string;
|
||||
reason?: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounting summary/report
|
||||
*/
|
||||
export interface IReq_GetRadiusAccountingSummary extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRadiusAccountingSummary
|
||||
> {
|
||||
method: 'getRadiusAccountingSummary';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
response: {
|
||||
summary: {
|
||||
periodStart: number;
|
||||
periodEnd: number;
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
totalInputBytes: number;
|
||||
totalOutputBytes: number;
|
||||
totalSessionTime: number;
|
||||
averageSessionDuration: number;
|
||||
uniqueUsers: number;
|
||||
sessionsByVlan: Record<number, number>;
|
||||
topUsersByTraffic: Array<{ username: string; totalBytes: number }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get RADIUS server statistics
|
||||
*/
|
||||
export interface IReq_GetRadiusStatistics extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRadiusStatistics
|
||||
> {
|
||||
method: 'getRadiusStatistics';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
stats: {
|
||||
running: boolean;
|
||||
uptime: number;
|
||||
authRequests: number;
|
||||
authAccepts: number;
|
||||
authRejects: number;
|
||||
accountingRequests: number;
|
||||
activeSessions: number;
|
||||
vlanMappings: number;
|
||||
clients: number;
|
||||
};
|
||||
vlanStats: {
|
||||
totalMappings: number;
|
||||
enabledMappings: number;
|
||||
exactMatches: number;
|
||||
ouiPatterns: number;
|
||||
wildcardPatterns: number;
|
||||
};
|
||||
accountingStats: {
|
||||
activeSessions: number;
|
||||
totalSessionsStarted: number;
|
||||
totalSessionsStopped: number;
|
||||
totalInputBytes: number;
|
||||
totalOutputBytes: number;
|
||||
interimUpdatesReceived: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '2.12.6',
|
||||
version: '4.0.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -53,6 +53,20 @@ export interface INetworkState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface IEmailOpsState {
|
||||
currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
||||
queuedEmails: interfaces.requests.IEmailQueueItem[];
|
||||
sentEmails: interfaces.requests.IEmailQueueItem[];
|
||||
failedEmails: interfaces.requests.IEmailQueueItem[];
|
||||
securityIncidents: interfaces.requests.ISecurityIncident[];
|
||||
bounceRecords: interfaces.requests.IBounceRecord[];
|
||||
suppressionList: string[];
|
||||
selectedEmailId: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
// Create state parts with appropriate persistence
|
||||
export const loginStatePart = await appState.getStatePart<ILoginState>(
|
||||
'login',
|
||||
@@ -60,7 +74,7 @@ export const loginStatePart = await appState.getStatePart<ILoginState>(
|
||||
identity: null,
|
||||
isLoggedIn: false,
|
||||
},
|
||||
'soft' // Login state persists across sessions
|
||||
'persistent' // Login state persists across browser sessions
|
||||
);
|
||||
|
||||
export const statsStatePart = await appState.getStatePart<IStatsState>(
|
||||
@@ -86,10 +100,19 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
||||
}
|
||||
);
|
||||
|
||||
// Determine initial view from URL path
|
||||
const getInitialView = (): string => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security'];
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
return validViews.includes(view) ? view : 'overview';
|
||||
};
|
||||
|
||||
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||
'ui',
|
||||
{
|
||||
activeView: 'overview',
|
||||
activeView: getInitialView(),
|
||||
sidebarCollapsed: false,
|
||||
autoRefresh: true,
|
||||
refreshInterval: 1000, // 1 second
|
||||
@@ -121,6 +144,24 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
||||
'soft'
|
||||
);
|
||||
|
||||
export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
||||
'emailOps',
|
||||
{
|
||||
currentView: 'queued',
|
||||
queuedEmails: [],
|
||||
sentEmails: [],
|
||||
failedEmails: [],
|
||||
securityIncidents: [],
|
||||
bounceRecords: [],
|
||||
suppressionList: [],
|
||||
selectedEmailId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
'soft'
|
||||
);
|
||||
|
||||
// Actions for state management
|
||||
interface IActionContext {
|
||||
identity: interfaces.data.IIdentity | null;
|
||||
@@ -226,7 +267,7 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Configuration Action
|
||||
// Fetch Configuration Action (read-only)
|
||||
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
|
||||
@@ -255,35 +296,6 @@ export const fetchConfigurationAction = configStatePart.createAction(async (stat
|
||||
}
|
||||
});
|
||||
|
||||
// Update Configuration Action
|
||||
export const updateConfigurationAction = configStatePart.createAction<{
|
||||
section: string;
|
||||
config: any;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) {
|
||||
throw new Error('Must be logged in to update configuration');
|
||||
}
|
||||
|
||||
const updateRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateConfiguration
|
||||
>('/typedrequest', 'updateConfiguration');
|
||||
|
||||
const response = await updateRequest.fire({
|
||||
identity: context.identity,
|
||||
section: dataArg.section,
|
||||
config: dataArg.config,
|
||||
});
|
||||
|
||||
if (response.updated) {
|
||||
// Refresh configuration
|
||||
await configStatePart.dispatchAction(fetchConfigurationAction, null);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
|
||||
return statePartArg.getState();
|
||||
});
|
||||
|
||||
// Fetch Recent Logs Action
|
||||
export const fetchRecentLogsAction = logStatePart.createAction<{
|
||||
limit?: number;
|
||||
@@ -397,6 +409,238 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Email Operations Actions
|
||||
// ============================================================================
|
||||
|
||||
// Set Email Ops View Action
|
||||
export const setEmailOpsViewAction = emailOpsStatePart.createAction<IEmailOpsState['currentView']>(
|
||||
async (statePartArg, view) => {
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
currentView: view,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch Queued Emails Action
|
||||
export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetQueuedEmails
|
||||
>('/typedrequest', 'getQueuedEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
status: 'pending',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
queuedEmails: response.items,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch queued emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Sent Emails Action
|
||||
export const fetchSentEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSentEmails
|
||||
>('/typedrequest', 'getSentEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
sentEmails: response.items,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch sent emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Failed Emails Action
|
||||
export const fetchFailedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetFailedEmails
|
||||
>('/typedrequest', 'getFailedEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
failedEmails: response.items,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch failed emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Security Incidents Action
|
||||
export const fetchSecurityIncidentsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSecurityIncidents
|
||||
>('/typedrequest', 'getSecurityIncidents');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
securityIncidents: response.incidents,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch security incidents',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Bounce Records Action
|
||||
export const fetchBounceRecordsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetBounceRecords
|
||||
>('/typedrequest', 'getBounceRecords');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
bounceRecords: response.records,
|
||||
suppressionList: response.suppressionList,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch bounce records',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Resend Failed Email Action
|
||||
export const resendEmailAction = emailOpsStatePart.createAction<string>(async (statePartArg, emailId) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ResendEmail
|
||||
>('/typedrequest', 'resendEmail');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
emailId,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Refresh failed emails list
|
||||
await emailOpsStatePart.dispatchAction(fetchFailedEmailsAction, null);
|
||||
await emailOpsStatePart.dispatchAction(fetchQueuedEmailsAction, null);
|
||||
}
|
||||
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to resend email',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Remove from Suppression List Action
|
||||
export const removeFromSuppressionListAction = emailOpsStatePart.createAction<string>(
|
||||
async (statePartArg, email) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_RemoveFromSuppressionList
|
||||
>('/typedrequest', 'removeFromSuppressionList');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
email,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Refresh bounce records
|
||||
await emailOpsStatePart.dispatchAction(fetchBounceRecordsAction, null);
|
||||
}
|
||||
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Combined refresh action for efficient polling
|
||||
async function dispatchCombinedRefreshAction() {
|
||||
const context = getActionContext();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import { appRouter } from '../router.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -21,12 +22,12 @@ import { OpsViewSecurity } from './ops-view-security.js';
|
||||
|
||||
@customElement('ops-dashboard')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@state() private loginState: appstate.ILoginState = {
|
||||
@state() accessor loginState: appstate.ILoginState = {
|
||||
identity: null,
|
||||
isLoggedIn: false,
|
||||
};
|
||||
|
||||
@state() private uiState: appstate.IUiState = {
|
||||
@state() accessor uiState: appstate.IUiState = {
|
||||
activeView: 'overview',
|
||||
sidebarCollapsed: false,
|
||||
autoRefresh: true,
|
||||
@@ -62,6 +63,14 @@ export class OpsDashboard extends DeesElement {
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the current view tab based on the UI state's activeView.
|
||||
* Used to pass the correct selectedView to dees-simple-appdash on initial render.
|
||||
*/
|
||||
private get currentViewTab() {
|
||||
return this.viewTabs.find(t => t.name.toLowerCase() === this.uiState.activeView) || this.viewTabs[0];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
document.title = 'DCRouter OpsServer';
|
||||
@@ -84,17 +93,55 @@ export class OpsDashboard extends DeesElement {
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((uiState) => {
|
||||
this.uiState = uiState;
|
||||
// Sync appdash view when state changes (e.g., from URL navigation)
|
||||
this.syncAppdashView(uiState.activeView);
|
||||
});
|
||||
this.rxSubscriptions.push(uiSubscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the dees-simple-appdash view selection with the current state.
|
||||
* This is needed when the URL changes and we need to update the UI.
|
||||
*/
|
||||
private syncAppdashView(viewName: string): void {
|
||||
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
|
||||
if (!appDash) return;
|
||||
|
||||
const targetTab = this.viewTabs.find(t => t.name.toLowerCase() === viewName);
|
||||
if (!targetTab) return;
|
||||
|
||||
// Check if we need to switch (avoid unnecessary updates)
|
||||
if (appDash.selectedView === targetTab) return;
|
||||
|
||||
// Update the selected view programmatically
|
||||
appDash.selectedView = targetTab;
|
||||
|
||||
// Update the displayed content
|
||||
const content = appDash.shadowRoot?.querySelector('.appcontent');
|
||||
if (content) {
|
||||
if (appDash.currentView) {
|
||||
appDash.currentView.remove();
|
||||
}
|
||||
const view = new targetTab.element();
|
||||
content.appendChild(view);
|
||||
appDash.currentView = view;
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -108,6 +155,7 @@ export class OpsDashboard extends DeesElement {
|
||||
<dees-simple-appdash
|
||||
name="DCRouter OpsServer"
|
||||
.viewTabs=${this.viewTabs}
|
||||
.selectedView=${this.currentViewTab}
|
||||
>
|
||||
</dees-simple-appdash>
|
||||
</dees-simple-login>
|
||||
@@ -126,8 +174,9 @@ export class OpsDashboard extends DeesElement {
|
||||
const appDash = this.shadowRoot.querySelector('dees-simple-appdash');
|
||||
if (appDash) {
|
||||
appDash.addEventListener('view-select', (e: CustomEvent) => {
|
||||
const viewName = e.detail.view.name;
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, viewName.toLowerCase());
|
||||
const viewName = e.detail.view.name.toLowerCase();
|
||||
// Use router for navigation instead of direct state update
|
||||
appRouter.navigateToView(viewName);
|
||||
});
|
||||
|
||||
// Handle logout event
|
||||
@@ -136,14 +185,20 @@ export class OpsDashboard extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle initial state
|
||||
// Handle initial state - check if we have a stored session that's still valid
|
||||
const loginState = appstate.loginStatePart.getState();
|
||||
// Check initial login state
|
||||
if (loginState.identity) {
|
||||
if (loginState.identity?.jwt) {
|
||||
// Verify JWT hasn't expired
|
||||
if (loginState.identity.expiresAt > Date.now()) {
|
||||
// JWT still valid, restore logged-in state
|
||||
this.loginState = loginState;
|
||||
await simpleLogin.switchToSlottedContent();
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
} else {
|
||||
// JWT expired, clear the stored state
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,23 +9,18 @@ import {
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('ops-view-config')
|
||||
export class OpsViewConfig extends DeesElement {
|
||||
@state()
|
||||
private configState: appstate.IConfigState = {
|
||||
accessor configState: appstate.IConfigState = {
|
||||
config: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@state()
|
||||
private editingSection: string | null = null;
|
||||
|
||||
@state()
|
||||
private editedConfig: any = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.configStatePart
|
||||
@@ -61,6 +56,14 @@ export class OpsViewConfig extends DeesElement {
|
||||
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 {
|
||||
@@ -71,12 +74,18 @@ export class OpsViewConfig extends DeesElement {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.configField:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
@@ -84,41 +93,77 @@ export class OpsViewConfig extends DeesElement {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
.configEditor {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 4px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||
resize: vertical;
|
||||
.fieldValue.empty {
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
.nestedFields {
|
||||
margin-left: 16px;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: ${cssManager.bdTheme('#fff3cd', '#4a4a1a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ffeaa7', '#666633')};
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#856404', '#ffcc66')};
|
||||
display: flex;
|
||||
/* 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')};
|
||||
@@ -133,6 +178,23 @@ export class OpsViewConfig extends DeesElement {
|
||||
padding: 40px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.infoNote {
|
||||
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
|
||||
border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')};
|
||||
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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -150,119 +212,175 @@ export class OpsViewConfig extends DeesElement {
|
||||
Error loading configuration: ${this.configState.error}
|
||||
</div>
|
||||
` : this.configState.config ? html`
|
||||
<div class="warning">
|
||||
<dees-icon name="warning"></dees-icon>
|
||||
<span>Changes to configuration will take effect immediately. Please be careful when editing production settings.</span>
|
||||
<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('server', 'Server Configuration', this.configState.config.server)}
|
||||
${this.renderConfigSection('email', 'Email Configuration', this.configState.config.email)}
|
||||
${this.renderConfigSection('dns', 'DNS Configuration', this.configState.config.dns)}
|
||||
${this.renderConfigSection('security', 'Security Configuration', this.configState.config.security)}
|
||||
${this.renderConfigSection('storage', 'Storage Configuration', this.configState.config.storage)}
|
||||
${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>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConfigSection(key: string, title: string, config: any) {
|
||||
const isEditing = this.editingSection === key;
|
||||
private renderConfigSection(key: string, title: string, icon: string, config: any) {
|
||||
const isEnabled = config?.enabled ?? false;
|
||||
|
||||
return html`
|
||||
<div class="configSection">
|
||||
<div class="sectionHeader">
|
||||
<h3 class="sectionTitle">${title}</h3>
|
||||
<div>
|
||||
${isEditing ? html`
|
||||
<dees-button
|
||||
@click=${() => this.saveConfig(key)}
|
||||
type="highlighted"
|
||||
>
|
||||
Save
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.cancelEdit()}
|
||||
>
|
||||
Cancel
|
||||
</dees-button>
|
||||
` : html`
|
||||
<dees-button
|
||||
@click=${() => this.startEdit(key, config)}
|
||||
>
|
||||
Edit
|
||||
</dees-button>
|
||||
`}
|
||||
</div>
|
||||
<h3 class="sectionTitle">
|
||||
<dees-icon icon="${icon}"></dees-icon>
|
||||
${title}
|
||||
</h3>
|
||||
${this.renderStatusBadge(isEnabled)}
|
||||
</div>
|
||||
<div class="sectionContent">
|
||||
${isEditing ? html`
|
||||
<textarea
|
||||
class="configEditor"
|
||||
@input=${(e) => this.editedConfig = e.target.value}
|
||||
.value=${JSON.stringify(config, null, 2)}
|
||||
></textarea>
|
||||
` : html`
|
||||
${this.renderConfigFields(config)}
|
||||
${config ? this.renderConfigFields(config) : html`
|
||||
<div class="fieldValue empty">Not configured</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConfigFields(config: any, prefix = '') {
|
||||
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 renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return html`<div class="fieldValue">${config}</div>`;
|
||||
return html`<div class="fieldValue">${this.formatValue(config)}</div>`;
|
||||
}
|
||||
|
||||
return Object.entries(config).map(([key, value]) => {
|
||||
const fieldName = prefix ? `${prefix}.${key}` : key;
|
||||
const displayName = this.formatFieldName(key);
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
// Handle boolean values with badges
|
||||
if (typeof value === 'boolean') {
|
||||
return html`
|
||||
<div class="configField">
|
||||
<label class="fieldLabel">${fieldName}</label>
|
||||
<label class="fieldLabel">${displayName}</label>
|
||||
${this.renderStatusBadge(value)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
return html`
|
||||
<div class="configField">
|
||||
<label class="fieldLabel">${displayName}</label>
|
||||
${this.renderArrayValue(value, key)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Handle nested objects
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return html`
|
||||
<div class="configField">
|
||||
<label class="fieldLabel">${displayName}</label>
|
||||
<div class="nestedFields">
|
||||
${this.renderConfigFields(value, fieldName)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Handle primitive values
|
||||
return html`
|
||||
<div class="configField">
|
||||
<label class="fieldLabel">${fieldName}</label>
|
||||
<div class="fieldValue">
|
||||
${Array.isArray(value) ? value.join(', ') : value}
|
||||
</div>
|
||||
<label class="fieldLabel">${displayName}</label>
|
||||
<div class="fieldValue">${this.formatValue(value, key)}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
private startEdit(section: string, config: any) {
|
||||
this.editingSection = section;
|
||||
this.editedConfig = JSON.stringify(config, null, 2);
|
||||
private renderArrayValue(arr: any[], fieldKey: string): TemplateResult {
|
||||
if (arr.length === 0) {
|
||||
return html`<div class="fieldValue empty">None configured</div>`;
|
||||
}
|
||||
|
||||
private cancelEdit() {
|
||||
this.editingSection = null;
|
||||
this.editedConfig = null;
|
||||
// Determine if we should show as pills/tags
|
||||
const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number');
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
private async saveConfig(section: string) {
|
||||
try {
|
||||
const parsedConfig = JSON.parse(this.editedConfig);
|
||||
|
||||
await appstate.configStatePart.dispatchAction(appstate.updateConfigurationAction, {
|
||||
section,
|
||||
config: parsedConfig,
|
||||
});
|
||||
|
||||
this.editingSection = null;
|
||||
this.editedConfig = null;
|
||||
|
||||
// Configuration updated successfully
|
||||
} catch (error) {
|
||||
console.error(`Error updating configuration:`, error);
|
||||
// For complex arrays, show as JSON
|
||||
return html`
|
||||
<div class="fieldValue">
|
||||
${arr.length} items configured
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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'],
|
||||
};
|
||||
|
||||
const label = labels[fieldKey] || ['item', 'items'];
|
||||
return count === 1 ? label[0] : label[1];
|
||||
}
|
||||
|
||||
private formatFieldName(key: string): string {
|
||||
// Convert camelCase to readable format
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, str => str.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
private formatValue(value: any, fieldKey?: string): string | TemplateResult {
|
||||
if (value === null || value === undefined) {
|
||||
return html`<span class="empty">Not set</span>`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import {
|
||||
@customElement('ops-view-logs')
|
||||
export class OpsViewLogs extends DeesElement {
|
||||
@state()
|
||||
private logState: appstate.ILogState = {
|
||||
accessor logState: appstate.ILogState = {
|
||||
recentLogs: [],
|
||||
isStreaming: false,
|
||||
filters: {},
|
||||
|
||||
@@ -28,20 +28,20 @@ interface INetworkRequest {
|
||||
@customElement('ops-view-network')
|
||||
export class OpsViewNetwork extends DeesElement {
|
||||
@state()
|
||||
private statsState = appstate.statsStatePart.getState();
|
||||
accessor statsState = appstate.statsStatePart.getState();
|
||||
|
||||
@state()
|
||||
private networkState = appstate.networkStatePart.getState();
|
||||
accessor networkState = appstate.networkStatePart.getState();
|
||||
|
||||
|
||||
@state()
|
||||
private networkRequests: INetworkRequest[] = [];
|
||||
accessor networkRequests: INetworkRequest[] = [];
|
||||
|
||||
@state()
|
||||
private trafficDataIn: Array<{ x: string | number; y: number }> = [];
|
||||
accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
|
||||
|
||||
@state()
|
||||
private trafficDataOut: Array<{ x: string | number; y: number }> = [];
|
||||
accessor trafficDataOut: Array<{ x: string | number; y: number }> = [];
|
||||
|
||||
// Track if we need to update the chart to avoid unnecessary re-renders
|
||||
private lastChartUpdate = 0;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
@customElement('ops-view-overview')
|
||||
export class OpsViewOverview extends DeesElement {
|
||||
@state()
|
||||
private statsState: appstate.IStatsState = {
|
||||
accessor statsState: appstate.IStatsState = {
|
||||
serverStats: null,
|
||||
emailStats: null,
|
||||
dnsStats: null,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
@customElement('ops-view-security')
|
||||
export class OpsViewSecurity extends DeesElement {
|
||||
@state()
|
||||
private statsState: appstate.IStatsState = {
|
||||
accessor statsState: appstate.IStatsState = {
|
||||
serverStats: null,
|
||||
emailStats: null,
|
||||
dnsStats: null,
|
||||
@@ -26,7 +26,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
};
|
||||
|
||||
@state()
|
||||
private selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview';
|
||||
accessor selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -3,6 +3,10 @@ import * as plugins from './plugins.js';
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
import './elements/index.js';
|
||||
import { appRouter } from './router.js';
|
||||
|
||||
// Initialize router before rendering
|
||||
appRouter.init();
|
||||
|
||||
plugins.deesElement.render(html`
|
||||
<ops-dashboard></ops-dashboard>
|
||||
|
||||
230
ts_web/readme.md
Normal file
230
ts_web/readme.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# @serve.zone/dcrouter-web
|
||||
|
||||
Web-based Operations Dashboard for DCRouter. 🖥️
|
||||
|
||||
This module provides the frontend web application for the DCRouter OpsServer, built with modern web components using the `@design.estate/dees-element` library. It offers a comprehensive dashboard for monitoring and managing your DCRouter instance in real-time.
|
||||
|
||||
## 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.
|
||||
|
||||
## Features
|
||||
|
||||
### 🔐 **Secure Authentication**
|
||||
- JWT-based authentication flow
|
||||
- Automatic session management
|
||||
- Secure login with username/password
|
||||
|
||||
### 📊 **Overview Dashboard**
|
||||
- Real-time server statistics
|
||||
- Connection monitoring
|
||||
- Email throughput visualization
|
||||
- DNS query metrics
|
||||
- RADIUS session tracking
|
||||
|
||||
### 🌐 **Network View**
|
||||
- Active connection monitoring
|
||||
- SmartProxy route visualization
|
||||
- TCP/HTTP connection details
|
||||
- TLS certificate status
|
||||
|
||||
### 📧 **Email Management**
|
||||
- Email queue monitoring
|
||||
- Delivery status tracking
|
||||
- Bounce management
|
||||
- DKIM key status
|
||||
- Domain configuration overview
|
||||
|
||||
### 📜 **Log Viewer**
|
||||
- Real-time log streaming
|
||||
- Log level filtering (error, warning, info, debug)
|
||||
- Search and filter capabilities
|
||||
- Time-range selection
|
||||
|
||||
### ⚙️ **Configuration**
|
||||
- View current system configuration
|
||||
- Update settings via TypedRequest API
|
||||
- Route management
|
||||
- Domain management
|
||||
|
||||
### 🛡️ **Security Dashboard**
|
||||
- IP reputation monitoring
|
||||
- Rate limit status
|
||||
- Blocked connections
|
||||
- Security event tracking
|
||||
|
||||
## Architecture
|
||||
|
||||
The web application is built using:
|
||||
|
||||
- **@design.estate/dees-element** - Modern web component framework with lit-element
|
||||
- **@design.estate/dees-catalog** - Pre-built UI components (appdash, login, forms)
|
||||
- **@push.rocks/smartstate** - Reactive state management
|
||||
- **@api.global/typedrequest** - Type-safe API communication
|
||||
|
||||
### Component Structure
|
||||
|
||||
```
|
||||
ts_web/
|
||||
├── index.ts # Application entry point
|
||||
├── plugins.ts # External dependency imports
|
||||
├── router.ts # Client-side routing
|
||||
├── appstate.ts # State management
|
||||
└── elements/
|
||||
├── index.ts # Component barrel export
|
||||
├── ops-dashboard.ts # Main dashboard container
|
||||
├── ops-view-overview.ts # Overview statistics
|
||||
├── ops-view-network.ts # Network monitoring
|
||||
├── ops-view-emails.ts # Email management
|
||||
├── ops-view-logs.ts # Log viewer
|
||||
├── ops-view-config.ts # Configuration
|
||||
├── ops-view-security.ts # Security dashboard
|
||||
└── shared/
|
||||
├── index.ts # Shared component exports
|
||||
├── css.ts # Shared styles
|
||||
└── ops-sectionheading.ts # Section heading component
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
The application uses `@push.rocks/smartstate` for reactive state management:
|
||||
|
||||
### State Parts
|
||||
|
||||
```typescript
|
||||
// Login state
|
||||
interface ILoginState {
|
||||
identity: IIdentity | null;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
// UI state
|
||||
interface IUiState {
|
||||
activeView: string;
|
||||
sidebarCollapsed: boolean;
|
||||
autoRefresh: boolean;
|
||||
refreshInterval: number;
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
// Statistics state
|
||||
interface IStatsState {
|
||||
server: IServerStats;
|
||||
email: IEmailStats;
|
||||
dns: IDnsStats;
|
||||
connections: IConnectionInfo[];
|
||||
health: IHealthStatus;
|
||||
}
|
||||
|
||||
// Configuration state
|
||||
interface IConfigState {
|
||||
configuration: IDcRouterConfig;
|
||||
loading: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
- `loginAction` - Authenticate user
|
||||
- `logoutAction` - End session
|
||||
- `fetchAllStatsAction` - Refresh all statistics
|
||||
- `fetchConfigurationAction` - Load configuration (read-only)
|
||||
|
||||
## Client-Side Routing
|
||||
|
||||
The application includes client-side routing for deep linking:
|
||||
|
||||
```typescript
|
||||
// Routes
|
||||
/overview → Overview dashboard
|
||||
/network → Network monitoring
|
||||
/emails → Email management
|
||||
/logs → Log viewer
|
||||
/configuration → System configuration
|
||||
/security → Security dashboard
|
||||
```
|
||||
|
||||
URL state is synchronized with the UI, allowing bookmarking and sharing of specific views.
|
||||
|
||||
## Building
|
||||
|
||||
The web application is built using `@git.zone/tsbundle`:
|
||||
|
||||
```bash
|
||||
# Build the bundle
|
||||
pnpm run bundle
|
||||
|
||||
# Watch for development
|
||||
pnpm run watch
|
||||
```
|
||||
|
||||
The bundle is output to `./dist_serve/bundle.js` and served by the OpsServer.
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development
|
||||
|
||||
```typescript
|
||||
// Start DCRouter with OpsServer enabled
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
opsServerConfig: {
|
||||
port: 3000,
|
||||
admin: {
|
||||
username: 'admin',
|
||||
password: 'dev-password'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await router.start();
|
||||
// Dashboard at http://localhost:3000
|
||||
```
|
||||
|
||||
### Adding New Views
|
||||
|
||||
1. Create a new view component in `elements/`:
|
||||
```typescript
|
||||
import { DeesElement, customElement, html } from '@design.estate/dees-element';
|
||||
|
||||
@customElement('ops-view-myview')
|
||||
export class OpsViewMyView extends DeesElement {
|
||||
public render() {
|
||||
return html`<div>My custom view</div>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Import and add to `ops-dashboard.ts`:
|
||||
```typescript
|
||||
import { OpsViewMyView } from './ops-view-myview.js';
|
||||
|
||||
private viewTabs = [
|
||||
// ... existing tabs
|
||||
{ name: 'MyView', element: OpsViewMyView }
|
||||
];
|
||||
```
|
||||
|
||||
3. Add route in `router.ts` if needed.
|
||||
|
||||
## 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.
|
||||
181
ts_web/router.ts
Normal file
181
ts_web/router.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import * as plugins from './plugins.js';
|
||||
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 type TValidView = typeof validViews[number];
|
||||
export type TValidEmailFolder = typeof validEmailFolders[number];
|
||||
|
||||
class AppRouter {
|
||||
private router: InstanceType<typeof SmartRouter>;
|
||||
private initialized = false;
|
||||
private suppressStateUpdate = false;
|
||||
|
||||
constructor() {
|
||||
this.router = new SmartRouter({ debug: false });
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
if (this.initialized) return;
|
||||
this.setupRoutes();
|
||||
this.setupStateSync();
|
||||
this.handleInitialRoute();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
this.navigateTo('/overview');
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Only update URL if it doesn't match current state
|
||||
if (!currentPath.startsWith(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.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateViewState(view: string): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
if (currentState.activeView !== view) {
|
||||
appstate.uiStatePart.setState({
|
||||
...currentState,
|
||||
activeView: view,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
public navigateToView(view: string): void {
|
||||
if (validViews.includes(view as TValidView)) {
|
||||
this.navigateTo(`/${view}`);
|
||||
} else {
|
||||
this.navigateTo('/overview');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export const appRouter = new AppRouter();
|
||||
Reference in New Issue
Block a user