Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6b3625256 | |||
| 103680a3a0 | |||
| ba67e0d208 | |||
| e86fe0df7a | |||
| 71ee2133e4 | |||
| 6c8073b91a | |||
| 17bb63f129 | |||
| 2ec647cd6c | |||
| 01267cfeb5 | |||
| eef053bd66 | |||
| ccb4dea91e | |||
| b0b480873f | |||
| 496dba94b1 | |||
| 69dbc29662 | |||
| 3bd6d2f2de | |||
| 2c8cc93952 | |||
| 3f50518b80 | |||
| 15ca5d137c | |||
| 16a4b04dfb | |||
| 03b494018a | |||
| 9c08384df0 | |||
| 9286f56316 |
@@ -4,11 +4,102 @@
|
||||
|
||||
|
||||
|
||||
## 2026-06-05 - 14.0.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- apply inbound PROXY protocol policies per listener (proxy-protocol)
|
||||
- Apply inbound PROXY protocol policies across prepared and runtime routes that share the same listener.
|
||||
- Require PROXY protocol for remote ingress SMTP and submission ports while using optional mode for other remote ingress and VPN listeners.
|
||||
- Trust localhost for remote ingress and VPN forwarding without globally enabling PROXY protocol.
|
||||
- Bump @push.rocks/smartproxy to ^27.12.8.
|
||||
|
||||
## 2026-06-04 - 14.0.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- remove legacy config seeding and route-based certificate reprovisioning (config)
|
||||
- Make ACME configuration DB-backed only and report DB-backed ACME state in the OpsServer config response.
|
||||
- Stop seeding DNS domains and records from constructor config at runtime.
|
||||
- Remove the route-name certificate reprovision typed request; domain-based reprovisioning remains available.
|
||||
- Remove legacy string email-domain normalization from runtime email startup.
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @push.rocks/smartproxy to ^27.12.7 (deps)
|
||||
- Consumes the upstream SmartProxy socket-handler relay fix for server-first SMTP banners.
|
||||
- Updates the lockfile to resolve @push.rocks/smartproxy 27.12.7.
|
||||
- use exact SmartData collection names in DNS migrations (migrations)
|
||||
- Updates DNS source rename migrations to use `DomainDoc` and `DnsRecordDoc` collection names.
|
||||
- Adds migration coverage for exact SmartData collection names.
|
||||
|
||||
## 2026-06-04 - 13.45.0
|
||||
|
||||
### Fixes
|
||||
|
||||
- relay server-first SMTP banners for generated email routes (email)
|
||||
- Convert generated plaintext email forward routes to runtime socket handlers for SmartProxy bootstrap.
|
||||
- Hydrate DB-backed generated email routes to the same runtime handlers when their email system keys match.
|
||||
- Add bidirectional socket proxy cleanup and tests for route hydration and SMTP banner relay.
|
||||
|
||||
### Features
|
||||
|
||||
- add route source policy editor (network-routes)
|
||||
- Replace fixed source binding dropdown rows with the catalog route source policy input in route create and edit dialogs.
|
||||
- Add source profile normalization, path class options, Gitea source policy presets, and validation for route source policies.
|
||||
- Bump catalog UI dependencies and update pnpm built dependency configuration.
|
||||
|
||||
## 2026-06-04 - 13.44.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- use smartdata cached document support (db)
|
||||
- Migrate cached email and IP reputation documents to SmartdataCachedDocument and shared smartdata TTL values.
|
||||
- Remove the local cached document base class and TTL export.
|
||||
- Bump @push.rocks/smartdata to ^7.2.0.
|
||||
|
||||
## 2026-06-04 - 13.44.0
|
||||
|
||||
### Features
|
||||
|
||||
- add DB-backed email and RemoteIngress hub settings (settings)
|
||||
- Add persisted email server settings with ops API handlers and web UI controls.
|
||||
- Extend RemoteIngress hub settings to manage enabled state, tunnel port, hub domain, and performance from the database.
|
||||
- Backfill email and RemoteIngress singleton settings from legacy bootstrap configuration during migrations.
|
||||
- Serialize SmartProxy, RemoteIngress, and email lifecycle updates to avoid overlapping runtime reconfiguration.
|
||||
|
||||
## 2026-06-03 - 13.43.5
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @serve.zone/catalog to ^2.12.8 (deps)
|
||||
- Updated @serve.zone/catalog from ^2.12.7 to ^2.12.8.
|
||||
|
||||
## 2026-06-03 - 13.43.4
|
||||
|
||||
### Fixes
|
||||
|
||||
- track tunnel streams using summary events (remoteingress)
|
||||
- Enable summary stream event mode for the RemoteIngress hub.
|
||||
- Synchronize active tunnel counts and stream totals from stream summary events.
|
||||
- Bump @serve.zone/remoteingress to ^4.23.0.
|
||||
- Remove obsolete Deno import map entries.
|
||||
|
||||
## 2026-06-03 - 13.43.3
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @push.rocks/smartproxy to ^27.12.6 (deps)
|
||||
- Updates package and Deno import dependencies from @push.rocks/smartproxy ^27.12.4 to ^27.12.6.
|
||||
|
||||
## 2026-06-03 - 13.43.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- enforce canonical source bindings for route access (route-management)
|
||||
- Convert route access metadata to ordered `metadata.sourceBindings[]` and remove active runtime use of legacy source policy/source profile fields.
|
||||
- Fail closed for managed gateway/workhoster routes without source bindings and add terminal deny fallbacks for private-only bindings.
|
||||
- Add migration coverage, Ops route UI updates, and documentation for the canonical source binding model.
|
||||
|
||||
## 2026-06-03 - 13.43.1
|
||||
|
||||
|
||||
@@ -1,49 +1,10 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.43.1",
|
||||
"version": "14.0.1",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
"dist_serve"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.2",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.7",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.4",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.4.0/server",
|
||||
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
|
||||
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
|
||||
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
|
||||
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.2",
|
||||
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
|
||||
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
|
||||
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.2",
|
||||
"@push.rocks/smartmetrics": "npm:@push.rocks/smartmetrics@^3.0.3",
|
||||
"@push.rocks/smartmigration": "npm:@push.rocks/smartmigration@1.4.1",
|
||||
"@push.rocks/smartmta": "npm:@push.rocks/smartmta@^5.3.3",
|
||||
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
|
||||
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
|
||||
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.4",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.3.0",
|
||||
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
|
||||
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
|
||||
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
|
||||
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
|
||||
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^6.2.1",
|
||||
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.5",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
|
||||
"lru-cache": "npm:lru-cache@^11.5.1",
|
||||
"qrcode": "npm:qrcode@^1.5.4",
|
||||
"uuid": "npm:uuid@^14.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.43.1",
|
||||
"version": "14.0.1",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -41,14 +41,14 @@
|
||||
"@api.global/typedserver": "^8.4.7",
|
||||
"@api.global/typedsocket": "^4.1.4",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.83.0",
|
||||
"@design.estate/dees-catalog": "^3.84.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@idp.global/sdk": "^1.4.0",
|
||||
"@push.rocks/lik": "^6.4.1",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.4",
|
||||
"@push.rocks/smartacme": "^9.5.0",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartdata": "^7.2.0",
|
||||
"@push.rocks/smartdb": "^2.10.2",
|
||||
"@push.rocks/smartdns": "^7.9.3",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@push.rocks/smartnetwork": "^4.7.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartproxy": "^27.12.4",
|
||||
"@push.rocks/smartproxy": "^27.12.8",
|
||||
"@push.rocks/smartradius": "^1.3.0",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
@@ -69,9 +69,9 @@
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.20.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.7",
|
||||
"@serve.zone/catalog": "^2.13.0",
|
||||
"@serve.zone/interfaces": "^6.2.1",
|
||||
"@serve.zone/remoteingress": "^4.22.5",
|
||||
"@serve.zone/remoteingress": "^4.23.0",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
|
||||
Generated
+34
-38
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^3.83.0
|
||||
version: 3.83.0(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.84.0
|
||||
version: 3.84.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
@@ -45,8 +45,8 @@ importers:
|
||||
specifier: ^9.5.0
|
||||
version: 9.5.0(socks@2.8.8)
|
||||
'@push.rocks/smartdata':
|
||||
specifier: ^7.1.7
|
||||
version: 7.1.7(socks@2.8.8)
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0(socks@2.8.8)
|
||||
'@push.rocks/smartdb':
|
||||
specifier: ^2.10.2
|
||||
version: 2.10.2(@tiptap/pm@2.27.2)(socks@2.8.8)
|
||||
@@ -70,7 +70,7 @@ importers:
|
||||
version: 3.0.3
|
||||
'@push.rocks/smartmigration':
|
||||
specifier: 1.4.1
|
||||
version: 1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))
|
||||
version: 1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.2.0(socks@2.8.8))
|
||||
'@push.rocks/smartmta':
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
@@ -84,8 +84,8 @@ importers:
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.12.4
|
||||
version: 27.12.4
|
||||
specifier: ^27.12.8
|
||||
version: 27.12.8
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
@@ -108,14 +108,14 @@ importers:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
'@serve.zone/catalog':
|
||||
specifier: ^2.12.7
|
||||
version: 2.12.7(@tiptap/pm@2.27.2)
|
||||
specifier: ^2.13.0
|
||||
version: 2.13.0(@tiptap/pm@2.27.2)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
'@serve.zone/remoteingress':
|
||||
specifier: ^4.22.5
|
||||
version: 4.22.5
|
||||
specifier: ^4.23.0
|
||||
version: 4.23.0
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
@@ -365,8 +365,8 @@ packages:
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
'@design.estate/dees-catalog@3.83.0':
|
||||
resolution: {integrity: sha512-Ia4fwZ5ndziJkSE000nCro83rD8Rujki7ASHBQhL6ZDflZRJRlfuc13azVnQC2sazKlo/bWSgiiLcpc3V2IYrw==}
|
||||
'@design.estate/dees-catalog@3.84.0':
|
||||
resolution: {integrity: sha512-CYNsKwOcu3FvkA+G3fli4P9fVfDcMK3my5AbhN6jLNM0JPMlKyKV8s3q6bAqQPc9QGAtm+XhY2zLI4Cgurs2HA==}
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||
@@ -1283,8 +1283,8 @@ packages:
|
||||
'@push.rocks/smartdata@7.1.5':
|
||||
resolution: {integrity: sha512-7x7VedEg6RocWndqUPuTbY2Bh85Q/x0LOVHL4o/NVXyh3IGNtiVQ8ple4WR0qYqlHRAojX4eDSBPMiYzIasqAg==}
|
||||
|
||||
'@push.rocks/smartdata@7.1.7':
|
||||
resolution: {integrity: sha512-HDI/Q9dKybfsJ68oCzlE+S63Xpij9qXnMfi28yznKP0Li1ECVZZMDDGIW5IjsXlHjO+Q+RJMcVd72Pjt3QLY5Q==}
|
||||
'@push.rocks/smartdata@7.2.0':
|
||||
resolution: {integrity: sha512-pk1o/No8OHT/bwOZu/Ivy3WgQsZoRtEpk/6HzWHi5KflLoWYKB+qjjOqBaDFhAEdgddfJH9qtN23zTtpGImmUA==}
|
||||
|
||||
'@push.rocks/smartdb@2.10.2':
|
||||
resolution: {integrity: sha512-nH8GfKPviQho2n6bQxKCDbjTspUBtoyL/BPVA04lbA34dYM/y0+nTdTWa93Vt4TJYfUqpdh4zNu4y60zZNU40g==}
|
||||
@@ -1429,8 +1429,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.4':
|
||||
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
|
||||
|
||||
'@push.rocks/smartproxy@27.12.4':
|
||||
resolution: {integrity: sha512-oqr4IE4hqNOL38RRzCux/dF0PjKjeIf4Z/vbe2JXZUz9ZF2ANNPsLSvs0Z/LFlfsrvV35k0rqeEmBvKIq+1lVQ==}
|
||||
'@push.rocks/smartproxy@27.12.8':
|
||||
resolution: {integrity: sha512-d1sbo2avzFO9PUXpb2FuBKwSDoackxNPFOHvR8q0DBMMoAmxRVf0mmhVxWrvqbGMk2N9rtORve2g3TsMJRTZYQ==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.6':
|
||||
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
|
||||
@@ -1689,14 +1689,14 @@ packages:
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
'@serve.zone/catalog@2.12.7':
|
||||
resolution: {integrity: sha512-cYl1N32ORzyrWmH2LYL9dGWdVbtf7Fr0Os7OjTU2Uxn6sGna65vIeSIhxXZ6R9DF6UDP36yxaYvDV3aWNI01CQ==}
|
||||
'@serve.zone/catalog@2.13.0':
|
||||
resolution: {integrity: sha512-w2zfbcbJLR1jbwJQkeLNCOW/WB71FyMBfVb+uiIO5XTVK+7zTD0cFozySjBDOrueCFDcL7GcoO8Ohgs9jCfuhQ==}
|
||||
|
||||
'@serve.zone/interfaces@6.2.1':
|
||||
resolution: {integrity: sha512-t2wrpBmd8zDdnyeeY/LG2hfjCXdm/uTHB6oovJ/xHgOws1E2VimYJPFiN7zqs1aEJAmFukfgOq79+eZeq3hfWw==}
|
||||
|
||||
'@serve.zone/remoteingress@4.22.5':
|
||||
resolution: {integrity: sha512-P2aQ/0VLPATbIBMYm4DT2XqLnBa15WXB1HcyE7RMVNC5RntGbmfj5FES3R5OgxY1V00E7wTMTCNrkfbKhj5xqQ==}
|
||||
'@serve.zone/remoteingress@4.23.0':
|
||||
resolution: {integrity: sha512-ddF7k3ZfgpPn9rwfprDMWZR2CNzwQlmiETpST2obJPN5VrAZMLj2aT7yQYGiqLgVqUQBSXeSz9St2ygYyQ8PSQ==}
|
||||
hasBin: true
|
||||
|
||||
'@smithy/chunked-blob-reader-native@4.2.3':
|
||||
@@ -4276,7 +4276,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.4(@push.rocks/smartserve@2.0.4)
|
||||
'@cloudflare/workers-types': 4.20260602.1
|
||||
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.84.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
@@ -4809,7 +4809,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
'@design.estate/dees-catalog@3.83.0(@tiptap/pm@2.27.2)':
|
||||
'@design.estate/dees-catalog@3.84.0(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.6
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
@@ -5298,7 +5298,7 @@ snapshots:
|
||||
'@api.global/typedrequest': 3.3.2
|
||||
'@api.global/typedsocket': 4.1.4(@push.rocks/smartserve@2.0.4)
|
||||
'@idp.global/interfaces': 1.1.0
|
||||
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
|
||||
'@push.rocks/smartjson': 6.0.1
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
@@ -6020,7 +6020,7 @@ snapshots:
|
||||
'@apiclient.xyz/cloudflare': 7.1.0
|
||||
'@peculiar/x509': 2.0.0
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartdns': 7.9.3
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
@@ -6173,12 +6173,11 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/smartdata@7.1.7(socks@2.8.8)':
|
||||
'@push.rocks/smartdata@7.2.0(socks@2.8.8)':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartmongo': 5.1.1(socks@2.8.8)
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smartstring': 4.1.1
|
||||
@@ -6191,13 +6190,10 @@ snapshots:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- react
|
||||
- react-native-b4a
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
@@ -6418,13 +6414,13 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
|
||||
'@push.rocks/smartmigration@1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))':
|
||||
'@push.rocks/smartmigration@1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.2.0(socks@2.8.8))':
|
||||
dependencies:
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartversion': 3.1.0
|
||||
optionalDependencies:
|
||||
'@push.rocks/smartbucket': 4.6.1
|
||||
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
|
||||
|
||||
'@push.rocks/smartmime@2.0.4':
|
||||
dependencies:
|
||||
@@ -6435,7 +6431,7 @@ snapshots:
|
||||
'@push.rocks/smartmongo@5.1.1(socks@2.8.8)':
|
||||
dependencies:
|
||||
'@push.rocks/mongodump': 1.1.1(socks@2.8.8)
|
||||
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
|
||||
'@push.rocks/smartfs': 1.5.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
@@ -6462,7 +6458,7 @@ snapshots:
|
||||
'@push.rocks/smartmongo@7.0.0(socks@2.8.8)':
|
||||
dependencies:
|
||||
'@push.rocks/mongodump': 1.1.1(socks@2.8.8)
|
||||
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
mongodb-memory-server: 11.1.0(socks@2.8.8)
|
||||
@@ -6585,7 +6581,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.4': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.12.4':
|
||||
'@push.rocks/smartproxy@27.12.8':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
@@ -6919,9 +6915,9 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
selderee: 0.11.0
|
||||
|
||||
'@serve.zone/catalog@2.12.7(@tiptap/pm@2.27.2)':
|
||||
'@serve.zone/catalog@2.13.0(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.84.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-domtools': 2.5.6
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.9.0
|
||||
@@ -6938,7 +6934,7 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/remoteingress@4.22.5':
|
||||
'@serve.zone/remoteingress@4.23.0':
|
||||
dependencies:
|
||||
'@push.rocks/qenv': 6.1.4
|
||||
'@push.rocks/smartnftables': 1.2.0
|
||||
|
||||
+5
-4
@@ -1,4 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
mongodb-memory-server: true
|
||||
puppeteer: true
|
||||
onlyBuiltDependencies:
|
||||
- '@design.estate/dees-catalog'
|
||||
- esbuild
|
||||
- mongodb-memory-server
|
||||
- puppeteer
|
||||
|
||||
@@ -146,19 +146,20 @@ dcrouter keeps generated and operator-created routes separate so automation can
|
||||
|
||||
System routes are persisted with stable `systemKey` values. API-created routes are the editable route layer intended for operators and automation.
|
||||
|
||||
## Route Source Policies
|
||||
## Route Source Bindings
|
||||
|
||||
API-created route records pass `metadata.sourcePolicy` alongside the SmartProxy route config to express ordered source and path policy variants without duplicating whole routes by hand. A source policy contains ordered `bindings`, each pointing at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
|
||||
API-created route records pass ordered `metadata.sourceBindings[]` alongside the SmartProxy route config to express source and path policy variants without duplicating whole routes by hand. Each binding points at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
|
||||
|
||||
Runtime behavior:
|
||||
|
||||
- Source matching uses the referenced `SourceProfile.security.ipAllowList`.
|
||||
- Bindings are evaluated in order and the first matching source profile wins.
|
||||
- A matched binding that exceeds its configured rate or connection limit is terminal and returns `429`; dcrouter does not fall through to later bindings.
|
||||
- Source-policy rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-policy binding and path-policy overrides.
|
||||
- A public fallback binding must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
|
||||
- Create/update paths reject source policies with missing source profiles, source profiles without source matches, missing final all-source fallback, or any all-source binding that shadows later bindings; persisted invalid policies fail closed at compile time.
|
||||
- Server-side caps bound policy expansion to 16 source bindings, 12 path policies per binding, 64 path patterns per path policy, 256 characters and 8 wildcards per custom path pattern, and 512 compiled SmartProxy route-port variants per stored route.
|
||||
- Source-binding rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-binding and path-policy overrides.
|
||||
- Private-only binding lists are valid. dcrouter adds a same-match terminal deny fallback so unmatched sources fail closed.
|
||||
- A public or wildcard binding is optional. When present, it must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
|
||||
- Create/update paths reject source bindings with missing source profiles, source profiles without source matches, or any all-source binding that shadows later bindings; persisted invalid bindings fail closed at compile time.
|
||||
- Server-side caps bound policy expansion to 16 source bindings, 12 path policies per binding, 64 path patterns per path policy, 256 characters and 8 wildcards per custom path pattern, 512 compiled SmartProxy route-port variants per stored route, and enough priority headroom above the stored route priority for generated source-binding variants.
|
||||
|
||||
Path policies let a source binding override rate limits or connection limits for specific path classes. dcrouter currently ships Gitea-oriented classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`. Path-specific variants win over the same binding's fallback; if every path policy is path-specific, dcrouter adds a source-level fallback route for unmatched paths so normal browsing cannot fall through to a later source binding. The Gitea preset keeps `git-smart-http` high-limit and separate from HTML crawling paths so normal `git clone`, `git fetch`, `git push`, and Git LFS traffic are not subject to the lower HTML crawler limits.
|
||||
|
||||
@@ -177,45 +178,43 @@ const createRoutePayload = {
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: trustedProfileId,
|
||||
maxConnections: 5000,
|
||||
onExceeded: { type: '429' },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicProfileId,
|
||||
onExceeded: { type: '429' },
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
rateLimit: { enabled: true, maxRequests: 1200, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'static',
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'raw',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'archive',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'expensive-html',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: trustedProfileId,
|
||||
maxConnections: 5000,
|
||||
onExceeded: { type: '429' },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicProfileId,
|
||||
onExceeded: { type: '429' },
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
rateLimit: { enabled: true, maxRequests: 1200, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'static',
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'raw',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'archive',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'expensive-html',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import * as net from 'node:net';
|
||||
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
@@ -18,6 +19,68 @@ async function getFreePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function listen(server: net.Server, port: number = 0): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
const address = server.address();
|
||||
resolve(typeof address === 'object' && address ? address.port : port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function trackSocket(sockets: Set<net.Socket>, socket: net.Socket): void {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
}
|
||||
|
||||
async function closeServer(server: net.Server, sockets?: Set<net.Socket>): Promise<void> {
|
||||
for (const socket of sockets || []) {
|
||||
socket.destroy();
|
||||
}
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function readFirstSocketData(port: number): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const socket = net.connect({ host: '127.0.0.1', port });
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('end', onEnd);
|
||||
socket.removeListener('close', onClose);
|
||||
};
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
socket.destroy();
|
||||
callback();
|
||||
};
|
||||
timeout = setTimeout(() => {
|
||||
settle(() => reject(new Error('Timed out waiting for socket data')));
|
||||
}, 5000) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
timeout.unref?.();
|
||||
const onData = (data: Buffer) => settle(() => resolve(data.toString('utf8')));
|
||||
const onError = (error: Error) => settle(() => reject(error));
|
||||
const onEnd = () => settle(() => reject(new Error('Socket ended before data')));
|
||||
const onClose = () => settle(() => reject(new Error('Socket closed before data')));
|
||||
socket.once('data', onData);
|
||||
socket.once('error', onError);
|
||||
socket.once('end', onEnd);
|
||||
socket.once('close', onClose);
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
// Define custom port mapping
|
||||
const customPortMapping: Record<number, number> = {
|
||||
@@ -109,7 +172,10 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
});
|
||||
expect(customPortRoute).toBeTruthy();
|
||||
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
||||
expect(customPortRoute?.action.type).toEqual('forward');
|
||||
expect(customPortRoute?.action.targets[0].host).toEqual('localhost');
|
||||
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
|
||||
expect(customPortRoute?.remoteIngress).toBeUndefined();
|
||||
|
||||
// Check standard port mappings
|
||||
const smtpRoute = routes.find((r: any) => {
|
||||
@@ -126,6 +192,183 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Generated plaintext email routes hydrate to server-first socket handlers', async () => {
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
const router = new DcRouter({ emailConfig });
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
|
||||
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
|
||||
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
|
||||
|
||||
const hydrate = (routerArg: DcRouter, route: any, origin = 'email') => (routerArg as any)['hydrateStoredRouteForRuntime']({
|
||||
id: `${origin}-${route.name}`,
|
||||
route,
|
||||
enabled: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
createdBy: 'system',
|
||||
origin,
|
||||
systemKey: `${origin}:${route.name}`,
|
||||
});
|
||||
|
||||
const runtimeSmtpRoute = hydrate(router, smtpRoute);
|
||||
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
|
||||
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
|
||||
|
||||
const runtimeSubmissionRoute = hydrate(router, submissionRoute);
|
||||
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
|
||||
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
|
||||
|
||||
expect(hydrate(router, smtpsRoute)).toBeUndefined();
|
||||
expect(hydrate(router, smtpRoute, 'api')).toBeUndefined();
|
||||
|
||||
const remoteIngressRouter = new DcRouter({
|
||||
emailConfig,
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
});
|
||||
const staleSmtpRoute = {
|
||||
...smtpRoute,
|
||||
match: {
|
||||
...smtpRoute.match,
|
||||
inboundProxyProtocol: undefined,
|
||||
},
|
||||
};
|
||||
const runtimeRemoteSmtpRoute = hydrate(remoteIngressRouter, staleSmtpRoute);
|
||||
expect(runtimeRemoteSmtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Inbound PROXY policies are applied per listener', async () => {
|
||||
const router = new DcRouter({
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
});
|
||||
const routes = (router as any)['applyInboundProxyProtocolPolicies']([{
|
||||
name: 'remote-route',
|
||||
match: { ports: [443], domains: ['remote.example.com'] },
|
||||
remoteIngress: { enabled: true },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
}, {
|
||||
name: 'same-listener-direct-route',
|
||||
match: { ports: [443], domains: ['direct.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 9443 }],
|
||||
},
|
||||
}]);
|
||||
|
||||
expect(routes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
expect(routes[1].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
|
||||
const vpnRouter = new DcRouter({
|
||||
vpnConfig: { enabled: true },
|
||||
});
|
||||
const vpnRoutes = (vpnRouter as any)['applyInboundProxyProtocolPolicies']([{
|
||||
name: 'vpn-route',
|
||||
match: { ports: [9443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 9443 }],
|
||||
},
|
||||
}]);
|
||||
|
||||
expect(vpnRoutes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email socket handler relays server-first SMTP banners', async () => {
|
||||
const backendSockets = new Set<net.Socket>();
|
||||
const backend = net.createServer((socket) => {
|
||||
trackSocket(backendSockets, socket);
|
||||
socket.write('220 test.example ESMTP Service Ready\r\n');
|
||||
});
|
||||
const backendPort = await listen(backend);
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
const router = new DcRouter({
|
||||
emailConfig,
|
||||
emailPortConfig: {
|
||||
portMapping: { 2525: backendPort },
|
||||
},
|
||||
});
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
const route = routes.find((routeArg: any) => routeArg.name === 'email-port-2525-route');
|
||||
const runtimeRoute = (router as any)['createServerFirstEmailRuntimeRoute'](route);
|
||||
expect(runtimeRoute?.action.type).toEqual('socket-handler');
|
||||
|
||||
const frontendSockets = new Set<net.Socket>();
|
||||
const frontend = net.createServer((socket) => {
|
||||
trackSocket(frontendSockets, socket);
|
||||
runtimeRoute.action.socketHandler(socket, {
|
||||
port: 2525,
|
||||
clientIp: '127.0.0.1',
|
||||
serverIp: '127.0.0.1',
|
||||
routeName: route.name,
|
||||
timestamp: Date.now(),
|
||||
connectionId: 'test-email-proxy',
|
||||
});
|
||||
});
|
||||
const frontendPort = await listen(frontend);
|
||||
|
||||
try {
|
||||
const banner = await readFirstSocketData(frontendPort);
|
||||
expect(banner).toEqual('220 test.example ESMTP Service Ready\r\n');
|
||||
} finally {
|
||||
await closeServer(frontend, frontendSockets);
|
||||
await closeServer(backend, backendSockets);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email routes are exposed through RemoteIngress when enabled', async () => {
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
const router = new DcRouter({
|
||||
emailConfig,
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
});
|
||||
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
expect(routes.length).toEqual(3);
|
||||
for (const route of routes) {
|
||||
expect(route.remoteIngress).toEqual({ enabled: true });
|
||||
}
|
||||
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
|
||||
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
|
||||
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
|
||||
expect(smtpRoute?.match.transport).toEqual('tcp');
|
||||
expect(smtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
expect(submissionRoute?.match.transport).toEqual('tcp');
|
||||
expect(submissionRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
expect(smtpsRoute?.action.type).toEqual('forward');
|
||||
expect(smtpsRoute?.match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
const opsServerPort = await getFreePort();
|
||||
// Create a basic email configuration
|
||||
@@ -164,6 +407,54 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
await router.stop();
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config updates are serialized', async () => {
|
||||
const router = new DcRouter({
|
||||
tls: {
|
||||
contactEmail: 'test@example.com',
|
||||
},
|
||||
});
|
||||
const delay = async () => await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||
let activeLifecycleSteps = 0;
|
||||
let overlapped = false;
|
||||
|
||||
const enterLifecycleStep = async () => {
|
||||
activeLifecycleSteps++;
|
||||
if (activeLifecycleSteps > 1) {
|
||||
overlapped = true;
|
||||
}
|
||||
await delay();
|
||||
activeLifecycleSteps--;
|
||||
};
|
||||
|
||||
(router as any).stopUnifiedEmailComponents = async () => {
|
||||
await enterLifecycleStep();
|
||||
};
|
||||
(router as any).setupUnifiedEmailHandling = async () => {
|
||||
await enterLifecycleStep();
|
||||
};
|
||||
|
||||
const firstConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
hostname: 'first.mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
const secondConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2526],
|
||||
hostname: 'second.mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
router.updateEmailConfig(firstConfig),
|
||||
router.updateEmailConfig(secondConfig),
|
||||
]);
|
||||
|
||||
expect(overlapped).toEqual(false);
|
||||
expect(router.options.emailConfig?.hostname).toEqual('second.mail.example.com');
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op
|
||||
|
||||
@@ -3,7 +3,6 @@ import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
|
||||
import { DcRouterDb, DnsRecordDoc, DomainDoc, RouteDoc } from '../ts/db/index.js';
|
||||
import { DnsManager } from '../ts/dns/manager.dns.js';
|
||||
import { logger } from '../ts/logger.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
@@ -411,53 +410,21 @@ tap.test('RouteConfigManager clears remote ingress config when route patch sets
|
||||
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
|
||||
tap.test('DnsManager start does not seed constructor DNS config into DB', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const originalLog = logger.log.bind(logger);
|
||||
const warningMessages: string[] = [];
|
||||
|
||||
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
|
||||
if (level === 'warn') {
|
||||
warningMessages.push(message);
|
||||
}
|
||||
return originalLog(level, message, context || {});
|
||||
};
|
||||
const dnsManager = new DnsManager({
|
||||
dnsNsDomains: ['ns1.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
|
||||
smartProxyConfig: { routes: [] },
|
||||
});
|
||||
|
||||
try {
|
||||
const existingDomain = new DomainDoc();
|
||||
existingDomain.id = 'existing-domain';
|
||||
existingDomain.name = 'example.com';
|
||||
existingDomain.source = 'dcrouter';
|
||||
existingDomain.authoritative = true;
|
||||
existingDomain.createdAt = Date.now();
|
||||
existingDomain.updatedAt = Date.now();
|
||||
existingDomain.createdBy = 'test';
|
||||
await existingDomain.save();
|
||||
await dnsManager.start();
|
||||
|
||||
const dnsManager = new DnsManager({
|
||||
dnsNsDomains: ['ns1.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
|
||||
smartProxyConfig: { routes: [] },
|
||||
});
|
||||
|
||||
await dnsManager.start();
|
||||
|
||||
expect(
|
||||
warningMessages.some((message) =>
|
||||
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
|
||||
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
|
||||
),
|
||||
).toEqual(true);
|
||||
expect(
|
||||
warningMessages.some((message) =>
|
||||
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
|
||||
),
|
||||
).toEqual(false);
|
||||
} finally {
|
||||
(logger as any).log = originalLog;
|
||||
}
|
||||
expect(await DomainDoc.findAll()).toHaveLength(0);
|
||||
expect(await DnsRecordDoc.findAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('cleanup test db', async () => {
|
||||
|
||||
@@ -183,6 +183,50 @@ tap.test('EmailDomainManager start merges persisted managed domains after restar
|
||||
expect(managedDomain?.dnsMode).toEqual('internal-dns');
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager can resync managed domains after email settings replace runtime config', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('resync-domain', 'resync.example.com', 'provider');
|
||||
const stored = new EmailDomainDoc();
|
||||
stored.id = 'resync-email-domain';
|
||||
stored.domain = 'mail.resync.example.com';
|
||||
stored.linkedDomainId = linkedDomain.id;
|
||||
stored.subdomain = 'mail';
|
||||
stored.dkim = {
|
||||
selector: 'default',
|
||||
keySize: 2048,
|
||||
rotateKeys: false,
|
||||
rotationIntervalDays: 90,
|
||||
};
|
||||
stored.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
stored.createdAt = new Date().toISOString();
|
||||
stored.updatedAt = new Date().toISOString();
|
||||
await stored.save();
|
||||
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
await manager.start();
|
||||
expect(dcRouterStub.options.emailConfig.domains.some((domain) => domain.domain === 'mail.resync.example.com')).toEqual(true);
|
||||
|
||||
dcRouterStub.options.emailConfig = createBaseEmailConfig();
|
||||
manager.setBaseEmailDomains(dcRouterStub.options.emailConfig.domains);
|
||||
await manager.syncManagedDomainsToRuntime();
|
||||
|
||||
const resyncedDomains = dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain).sort();
|
||||
expect(resyncedDomains).toEqual(['mail.resync.example.com', 'static.example.com']);
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { DcRouterDb, EmailServerSettingsDoc } from '../ts/db/index.js';
|
||||
import { EmailSettingsManager } from '../ts/email/index.js';
|
||||
import type { IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-email-settings-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-email-settings-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearSettings = async () => {
|
||||
for (const doc of await EmailServerSettingsDoc.findAll()) {
|
||||
await doc.delete();
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('EmailSettingsManager does not backfill from legacy constructor options', async () => {
|
||||
await testDbPromise;
|
||||
await clearSettings();
|
||||
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig: {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587],
|
||||
domains: [],
|
||||
routes: [],
|
||||
maxMessageSize: 1024,
|
||||
},
|
||||
emailPortConfig: {
|
||||
portMapping: { 25: 10025, 587: 10587 },
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailSettingsManager(options);
|
||||
await manager.start();
|
||||
|
||||
expect(manager.getPublicSettings().enabled).toEqual(false);
|
||||
expect(manager.getPublicSettings().hostname).toEqual(null);
|
||||
expect(options.emailConfig).toBeUndefined();
|
||||
expect(options.emailPortConfig).toBeUndefined();
|
||||
|
||||
await clearSettings();
|
||||
const migratedDoc = new EmailServerSettingsDoc();
|
||||
migratedDoc.settingsId = 'email-server-settings';
|
||||
migratedDoc.enabled = true;
|
||||
migratedDoc.emailConfig = {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587],
|
||||
domains: [],
|
||||
routes: [],
|
||||
maxMessageSize: 1024,
|
||||
};
|
||||
migratedDoc.emailPortConfig = {
|
||||
portMapping: { 25: 10025, 587: 10587 },
|
||||
};
|
||||
migratedDoc.updatedAt = Date.now();
|
||||
migratedDoc.updatedBy = 'migration';
|
||||
await migratedDoc.save();
|
||||
|
||||
const secondOptions: IDcRouterOptions = {
|
||||
emailConfig: {
|
||||
hostname: 'ignored.example.com',
|
||||
ports: [2525],
|
||||
domains: [],
|
||||
routes: [],
|
||||
},
|
||||
};
|
||||
const secondManager = new EmailSettingsManager(secondOptions);
|
||||
await secondManager.start();
|
||||
|
||||
expect(secondManager.getPublicSettings().hostname).toEqual('mail.example.com');
|
||||
expect(secondOptions.emailConfig?.hostname).toEqual('mail.example.com');
|
||||
});
|
||||
|
||||
tap.test('EmailSettingsManager updates redacted mutable server settings', async () => {
|
||||
await testDbPromise;
|
||||
await clearSettings();
|
||||
|
||||
const options: IDcRouterOptions = {};
|
||||
const manager = new EmailSettingsManager(options);
|
||||
await manager.start();
|
||||
expect(manager.getPublicSettings().enabled).toEqual(false);
|
||||
expect(options.emailConfig).toBeUndefined();
|
||||
|
||||
const settings = await manager.updateSettings(
|
||||
{
|
||||
enabled: true,
|
||||
hostname: 'smtp.example.com',
|
||||
ports: [587, 25, 587],
|
||||
portMapping: { 25: 10025, 587: 10587 },
|
||||
maxMessageSize: 2048,
|
||||
},
|
||||
'tester',
|
||||
);
|
||||
|
||||
expect(settings.enabled).toEqual(true);
|
||||
expect(settings.ports).toEqual([25, 587]);
|
||||
expect(settings.portMapping?.[587]).toEqual(10587);
|
||||
expect(options.emailConfig?.hostname).toEqual('smtp.example.com');
|
||||
expect(options.emailConfig?.maxMessageSize).toEqual(2048);
|
||||
|
||||
await manager.updateSettings({ enabled: false }, 'tester');
|
||||
expect(manager.getPublicSettings().enabled).toEqual(false);
|
||||
expect(options.emailConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await clearSettings();
|
||||
await testDb.cleanup();
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -27,6 +27,24 @@ function applySet(document: Record<string, any>, set: Record<string, unknown>):
|
||||
}
|
||||
}
|
||||
|
||||
function unsetPath(target: Record<string, any>, path: string): void {
|
||||
const parts = path.split('.');
|
||||
let cursor: any = target;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (cursor?.[part] === undefined) return;
|
||||
cursor = cursor[part];
|
||||
}
|
||||
if (cursor && typeof cursor === 'object') {
|
||||
delete cursor[parts[parts.length - 1]];
|
||||
}
|
||||
}
|
||||
|
||||
function applyUnset(document: Record<string, any>, unset: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(unset)) {
|
||||
unsetPath(document, key);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(document: Record<string, any>, query: Record<string, any>): boolean {
|
||||
for (const [key, expected] of Object.entries(query)) {
|
||||
const actual = getPath(document, key);
|
||||
@@ -74,6 +92,7 @@ function createFakeCollection(documents: Array<Record<string, any>> = []) {
|
||||
for (const document of documents) {
|
||||
if (!matchesQuery(document, query)) continue;
|
||||
applySet(document, update.$set || {});
|
||||
applyUnset(document, update.$unset || {});
|
||||
modifiedCount++;
|
||||
}
|
||||
return { modifiedCount };
|
||||
@@ -82,6 +101,7 @@ function createFakeCollection(documents: Array<Record<string, any>> = []) {
|
||||
const document = documents.find((candidate) => matchesQuery(candidate, query));
|
||||
if (!document) return { matchedCount: 0, modifiedCount: 0, upsertedCount: 0 };
|
||||
applySet(document, update.$set || {});
|
||||
applyUnset(document, update.$unset || {});
|
||||
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
|
||||
},
|
||||
};
|
||||
@@ -145,6 +165,24 @@ tap.test('migration runner applies schema steps through the current target', asy
|
||||
expect(sourceProfiles.map((profile) => profile.name)).toContain('PUBLIC');
|
||||
});
|
||||
|
||||
tap.test('migration runner uses exact SmartData collection names for DNS source renames', async () => {
|
||||
const domains: Array<Record<string, any>> = [{ _id: 'domain-1', source: 'manual' }];
|
||||
const records: Array<Record<string, any>> = [{ _id: 'record-1', source: 'manual' }];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.1.0', {
|
||||
DomainDoc: domains,
|
||||
DnsRecordDoc: records,
|
||||
}),
|
||||
'13.8.2',
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(2);
|
||||
expect(domains[0].source).toEqual('dcrouter');
|
||||
expect(records[0].source).toEqual('local');
|
||||
});
|
||||
|
||||
tap.test('migration runner rematerializes source-profile-backed route security', async () => {
|
||||
const profiles: Array<Record<string, any>> = [
|
||||
{
|
||||
@@ -222,4 +260,177 @@ tap.test('migration runner seeds only missing default source profiles', async ()
|
||||
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
|
||||
});
|
||||
|
||||
tap.test('migration runner converts legacy route access metadata to source bindings', async () => {
|
||||
const profiles: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'profile-doc-1',
|
||||
id: 'standard-profile',
|
||||
name: 'Standard',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
},
|
||||
{
|
||||
_id: 'profile-doc-2',
|
||||
id: 'public-profile',
|
||||
name: 'PUBLIC',
|
||||
security: { ipAllowList: ['*'] },
|
||||
},
|
||||
];
|
||||
const routes: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'route-doc-1',
|
||||
id: 'route-1',
|
||||
route: {
|
||||
name: 'standard service',
|
||||
match: { ports: 443, domains: ['onebox.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.2', port: 443 }] },
|
||||
security: { ipAllowList: ['10.0.0.0/8'], maxConnections: 1000 },
|
||||
},
|
||||
metadata: {
|
||||
sourceProfileRef: 'standard-profile',
|
||||
sourceProfileName: 'Old Standard Name',
|
||||
},
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
_id: 'route-doc-2',
|
||||
id: 'route-2',
|
||||
route: {
|
||||
name: 'gitea',
|
||||
match: { ports: 443, domains: ['code.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.3', port: 3000 }] },
|
||||
security: { basicAuth: { username: 'user', password: 'pass' } },
|
||||
},
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'standard-profile' },
|
||||
{ sourceProfileRef: 'public-profile' },
|
||||
],
|
||||
},
|
||||
},
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.43.1', {
|
||||
SourceProfileDoc: profiles,
|
||||
RouteDoc: routes,
|
||||
}),
|
||||
'13.43.2',
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(routes[0].metadata.sourceBindings).toEqual([
|
||||
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Old Standard Name' },
|
||||
]);
|
||||
expect(routes[0].metadata.sourceProfileRef).toBeUndefined();
|
||||
expect(routes[0].metadata.sourceProfileName).toBeUndefined();
|
||||
expect(routes[0].metadata.sourcePolicy).toBeUndefined();
|
||||
expect(routes[0].route.security).toBeUndefined();
|
||||
expect(routes[1].metadata.sourceBindings).toEqual([
|
||||
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Standard' },
|
||||
{ sourceProfileRef: 'public-profile', sourceProfileName: 'PUBLIC' },
|
||||
]);
|
||||
expect(routes[1].metadata.sourcePolicy).toBeUndefined();
|
||||
expect(routes[1].route.security.basicAuth.username).toEqual('user');
|
||||
});
|
||||
|
||||
tap.test('migration runner backfills RemoteIngress hub settings from legacy config seed', async () => {
|
||||
const hubSettingsDocs: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'remote-ingress-settings-1',
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
performance: undefined,
|
||||
updatedAt: 1,
|
||||
updatedBy: '',
|
||||
},
|
||||
];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.43.5', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
|
||||
'13.43.6',
|
||||
{
|
||||
remoteIngressHubSettings: {
|
||||
enabled: true,
|
||||
tunnelPort: 29443,
|
||||
hubDomain: '203.0.113.10',
|
||||
performance: {
|
||||
profile: 'balanced',
|
||||
maxStreamsPerEdge: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(hubSettingsDocs[0].enabled).toEqual(true);
|
||||
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
|
||||
expect(hubSettingsDocs[0].hubDomain).toEqual('203.0.113.10');
|
||||
expect(hubSettingsDocs[0].performance.profile).toEqual('balanced');
|
||||
expect(hubSettingsDocs[0].performance.maxStreamsPerEdge).toEqual(10000);
|
||||
expect(hubSettingsDocs[0].updatedAt).not.toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('migration runner backfills RemoteIngress hub settings at current package target', async () => {
|
||||
const hubSettingsDocs: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'remote-ingress-settings-current',
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
updatedAt: 1,
|
||||
updatedBy: '',
|
||||
},
|
||||
];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.43.2', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
|
||||
'13.43.5',
|
||||
{
|
||||
remoteIngressHubSettings: {
|
||||
enabled: true,
|
||||
tunnelPort: 29443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(hubSettingsDocs[0].enabled).toEqual(true);
|
||||
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
|
||||
expect(hubSettingsDocs[0].hubDomain).toEqual('ingress.example.com');
|
||||
});
|
||||
|
||||
tap.test('migration runner backfills Email server settings from legacy config seed', async () => {
|
||||
const emailSettingsDocs: Array<Record<string, any>> = [];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.43.2', { EmailServerSettingsDoc: emailSettingsDocs }),
|
||||
'13.43.5',
|
||||
{
|
||||
emailServerSettings: {
|
||||
enabled: true,
|
||||
emailConfig: {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587],
|
||||
domains: [],
|
||||
routes: [],
|
||||
},
|
||||
emailPortConfig: {
|
||||
portMapping: { 25: 10025, 587: 10587 },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(emailSettingsDocs).toHaveLength(1);
|
||||
expect(emailSettingsDocs[0].enabled).toEqual(true);
|
||||
expect(emailSettingsDocs[0].emailConfig.hostname).toEqual('mail.example.com');
|
||||
expect(emailSettingsDocs[0].emailPortConfig.portMapping[25]).toEqual(10025);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
+46
-234
@@ -3,10 +3,6 @@ import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers: access private maps for direct unit testing without DB
|
||||
// ============================================================================
|
||||
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||
(resolver as any).profiles.set(profile.id, profile);
|
||||
}
|
||||
@@ -54,10 +50,6 @@ function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
||||
} as IRouteConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resolution tests
|
||||
// ============================================================================
|
||||
|
||||
let resolver: ReferenceResolver;
|
||||
|
||||
tap.test('should create ReferenceResolver instance', async () => {
|
||||
@@ -67,92 +59,43 @@ tap.test('should create ReferenceResolver instance', async () => {
|
||||
|
||||
tap.test('should list empty profiles and targets initially', async () => {
|
||||
expect(resolver.listProfiles()).toBeArray();
|
||||
expect(resolver.listProfiles().length).toEqual(0);
|
||||
expect(resolver.listProfiles()).toHaveLength(0);
|
||||
expect(resolver.listTargets()).toBeArray();
|
||||
expect(resolver.listTargets().length).toEqual(0);
|
||||
expect(resolver.listTargets()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---- Source profile resolution ----
|
||||
|
||||
tap.test('should resolve source profile onto a route', async () => {
|
||||
tap.test('should resolve source binding display names without materializing route security', async () => {
|
||||
const profile = makeProfile();
|
||||
injectProfile(resolver, profile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
const route = makeRoute({
|
||||
security: { ipAllowList: ['127.0.0.1'], maxConnections: 42 },
|
||||
});
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security).toBeTruthy();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.route.security!.ipAllowList).toEqual(['127.0.0.1']);
|
||||
expect(result.route.security!.maxConnections).toEqual(42);
|
||||
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should replace inline route security when source profile is selected', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.ipAllowList!.includes('127.0.0.1')).toBeFalse();
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('should remove stale wildcard security from a profile-backed route', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security!.ipAllowList!.includes('*')).toBeFalse();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('should deduplicate IP lists during merge', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
|
||||
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
|
||||
expect(count).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('should handle missing profile gracefully', async () => {
|
||||
tap.test('should keep missing source binding refs fail-closed for compiler validation', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceBindings: [{ sourceProfileRef: 'nonexistent-profile' }],
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be unchanged
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.sourceProfileName).toBeUndefined();
|
||||
expect(result.metadata.sourceBindings![0].sourceProfileName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Profile inheritance ----
|
||||
|
||||
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
tap.test('should resolve source profile inheritance for apply-time compiler use', async () => {
|
||||
const baseProfile = makeProfile({
|
||||
id: 'base-profile',
|
||||
name: 'BASE',
|
||||
@@ -173,46 +116,12 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
});
|
||||
injectProfile(resolver, extendedProfile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Should have IPs from both base and extended profiles
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||
// maxConnections from base (extended doesn't override)
|
||||
expect(result.route.security!.maxConnections).toEqual(500);
|
||||
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
|
||||
const security = resolver.resolveSourceProfileSecurity('extended-profile')!;
|
||||
expect(security.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(security.ipAllowList).toContain('160.79.104.0/21');
|
||||
expect(security.maxConnections).toEqual(500);
|
||||
});
|
||||
|
||||
tap.test('should detect circular profile inheritance', async () => {
|
||||
const profileA = makeProfile({
|
||||
id: 'circular-a',
|
||||
name: 'A',
|
||||
security: { ipAllowList: ['1.1.1.1'] },
|
||||
extendsProfiles: ['circular-b'],
|
||||
});
|
||||
const profileB = makeProfile({
|
||||
id: 'circular-b',
|
||||
name: 'B',
|
||||
security: { ipAllowList: ['2.2.2.2'] },
|
||||
extendsProfiles: ['circular-a'],
|
||||
});
|
||||
injectProfile(resolver, profileA);
|
||||
injectProfile(resolver, profileB);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
|
||||
|
||||
// Should not infinite loop — resolves what it can
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
expect(result.route.security).toBeTruthy();
|
||||
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
|
||||
});
|
||||
|
||||
// ---- Network target resolution ----
|
||||
|
||||
tap.test('should resolve network target onto a route', async () => {
|
||||
const target = makeTarget();
|
||||
injectTarget(resolver, target);
|
||||
@@ -222,86 +131,34 @@ tap.test('should resolve network target onto a route', async () => {
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.action.targets).toBeTruthy();
|
||||
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle missing target gracefully', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route targets should be unchanged (still the placeholder)
|
||||
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||
expect(result.metadata.networkTargetName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Combined resolution ----
|
||||
|
||||
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||
tap.test('should resolve source bindings and target references together', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceProfileRef: 'profile-1',
|
||||
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Security from profile
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
|
||||
// Target from network target
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
|
||||
// Both names recorded
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
});
|
||||
|
||||
tap.test('should skip resolution when no metadata refs', async () => {
|
||||
const route = makeRoute({
|
||||
security: { ipAllowList: ['1.2.3.4'] },
|
||||
});
|
||||
const metadata: IRouteMetadata = {};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be completely unchanged
|
||||
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
|
||||
expect(result.route.security!.ipAllowList!.length).toEqual(1);
|
||||
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||
});
|
||||
|
||||
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
const first = resolver.resolveRoute(route, metadata);
|
||||
const second = resolver.resolveRoute(first.route, first.metadata);
|
||||
|
||||
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
|
||||
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
|
||||
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
|
||||
});
|
||||
|
||||
// ---- Lookup helpers ----
|
||||
|
||||
tap.test('should find routes by profile ref (sync)', async () => {
|
||||
tap.test('should find routes by source binding profile ref only', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-a', {
|
||||
id: 'route-a',
|
||||
route: makeRoute({ name: 'route-a' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
|
||||
});
|
||||
storedRoutes.set('route-b', {
|
||||
id: 'route-b',
|
||||
@@ -313,62 +170,31 @@ tap.test('should find routes by profile ref (sync)', async () => {
|
||||
id: 'route-c',
|
||||
route: makeRoute({ name: 'route-c' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
});
|
||||
storedRoutes.set('route-d', {
|
||||
id: 'route-d',
|
||||
route: makeRoute({ name: 'route-d' }),
|
||||
enabled: true,
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
networkTargetRef: 'target-1',
|
||||
},
|
||||
});
|
||||
|
||||
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||
expect(profileRefs.length).toEqual(3);
|
||||
expect(profileRefs).toHaveLength(2);
|
||||
expect(profileRefs).toContain('route-a');
|
||||
expect(profileRefs).toContain('route-c');
|
||||
expect(profileRefs).toContain('route-d');
|
||||
|
||||
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||
expect(targetRefs.length).toEqual(2);
|
||||
expect(targetRefs).toHaveLength(2);
|
||||
expect(targetRefs).toContain('route-b');
|
||||
expect(targetRefs).toContain('route-c');
|
||||
});
|
||||
|
||||
tap.test('should resolve source policy binding display names', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.sourcePolicy!.bindings[0].sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||
tap.test('should get profile and target usage for specific IDs', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-x', {
|
||||
id: 'route-x',
|
||||
route: makeRoute({ name: 'my-route' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
|
||||
});
|
||||
|
||||
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||
expect(usage.length).toEqual(1);
|
||||
expect(usage[0].id).toEqual('route-x');
|
||||
expect(usage[0].routeName).toEqual('my-route');
|
||||
});
|
||||
|
||||
tap.test('should get target usage for a specific target ID', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-y', {
|
||||
id: 'route-y',
|
||||
route: makeRoute({ name: 'other-route' }),
|
||||
@@ -376,34 +202,20 @@ tap.test('should get target usage for a specific target ID', async () => {
|
||||
metadata: { networkTargetRef: 'target-1' },
|
||||
});
|
||||
|
||||
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||
expect(usage.length).toEqual(1);
|
||||
expect(usage[0].id).toEqual('route-y');
|
||||
expect(usage[0].routeName).toEqual('other-route');
|
||||
const profileUsage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||
expect(profileUsage).toHaveLength(1);
|
||||
expect(profileUsage[0].routeName).toEqual('my-route');
|
||||
|
||||
const targetUsage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||
expect(targetUsage).toHaveLength(1);
|
||||
expect(targetUsage[0].routeName).toEqual('other-route');
|
||||
});
|
||||
|
||||
// ---- Profile/target getters ----
|
||||
|
||||
tap.test('should get profile by name', async () => {
|
||||
const profile = resolver.getProfileByName('STANDARD');
|
||||
expect(profile).toBeTruthy();
|
||||
expect(profile!.id).toEqual('profile-1');
|
||||
});
|
||||
|
||||
tap.test('should get target by name', async () => {
|
||||
const target = resolver.getTargetByName('INFRA');
|
||||
expect(target).toBeTruthy();
|
||||
expect(target!.id).toEqual('target-1');
|
||||
});
|
||||
|
||||
tap.test('should return undefined for nonexistent profile name', async () => {
|
||||
const profile = resolver.getProfileByName('NONEXISTENT');
|
||||
expect(profile).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should return undefined for nonexistent target name', async () => {
|
||||
const target = resolver.getTargetByName('NONEXISTENT');
|
||||
expect(target).toBeUndefined();
|
||||
tap.test('should get profiles and targets by name', async () => {
|
||||
expect(resolver.getProfileByName('STANDARD')!.id).toEqual('profile-1');
|
||||
expect(resolver.getTargetByName('INFRA')!.id).toEqual('target-1');
|
||||
expect(resolver.getProfileByName('NONEXISTENT')).toBeUndefined();
|
||||
expect(resolver.getTargetByName('NONEXISTENT')).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { RemoteIngressManager } from '../ts/remoteingress/index.js';
|
||||
import { RemoteIngressHubSettingsDoc } from '../ts/db/index.js';
|
||||
|
||||
tap.test('RemoteIngressManager preserves omitted hub settings on partial update', async () => {
|
||||
const originalLoad = RemoteIngressHubSettingsDoc.load;
|
||||
const fakeDoc: any = {
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
enabled: true,
|
||||
tunnelPort: 29443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
performance: {
|
||||
totalWindowBudgetBytes: 134217728,
|
||||
},
|
||||
updatedAt: 1,
|
||||
updatedBy: 'seed',
|
||||
save: async () => undefined,
|
||||
};
|
||||
|
||||
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
|
||||
try {
|
||||
const manager = new RemoteIngressManager();
|
||||
const settings = await manager.updateHubSettings({
|
||||
performance: {
|
||||
maxStreamsPerEdge: 10000,
|
||||
},
|
||||
}, 'test-user');
|
||||
|
||||
expect(settings.enabled).toEqual(true);
|
||||
expect(settings.tunnelPort).toEqual(29443);
|
||||
expect(settings.hubDomain).toEqual('ingress.example.com');
|
||||
expect(settings.performance?.maxStreamsPerEdge).toEqual(10000);
|
||||
} finally {
|
||||
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('RemoteIngressManager clears optional hub settings explicitly', async () => {
|
||||
const originalLoad = RemoteIngressHubSettingsDoc.load;
|
||||
const fakeDoc: any = {
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
enabled: true,
|
||||
tunnelPort: 29443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
performance: {
|
||||
maxStreamsPerEdge: 10000,
|
||||
},
|
||||
updatedAt: 1,
|
||||
updatedBy: 'seed',
|
||||
save: async () => undefined,
|
||||
};
|
||||
|
||||
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
|
||||
try {
|
||||
const manager = new RemoteIngressManager();
|
||||
const settings = await manager.updateHubSettings({
|
||||
hubDomain: null,
|
||||
performance: null,
|
||||
}, 'test-user');
|
||||
|
||||
expect(settings.enabled).toEqual(true);
|
||||
expect(settings.tunnelPort).toEqual(29443);
|
||||
expect(settings.hubDomain).toBeUndefined();
|
||||
expect(settings.performance).toBeUndefined();
|
||||
} finally {
|
||||
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -55,13 +55,11 @@ tap.test('source policy compiler expands one route into ordered source variants'
|
||||
}));
|
||||
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'ai' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'ai' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
};
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
|
||||
@@ -76,7 +74,7 @@ tap.test('source policy compiler expands one route into ordered source variants'
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
|
||||
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
|
||||
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority);
|
||||
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority! + 1);
|
||||
});
|
||||
|
||||
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
|
||||
@@ -91,15 +89,13 @@ tap.test('source policy binding can override profile rate limit and 429 message'
|
||||
}));
|
||||
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
|
||||
onExceeded: { type: '429', errorMessage: 'Slow down' },
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
|
||||
onExceeded: { type: '429', errorMessage: 'Slow down' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [variant] = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
|
||||
@@ -128,27 +124,25 @@ tap.test('source policy compiler forces source-policy rate limits to source IP k
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'header',
|
||||
headerName: 'x-client-id',
|
||||
},
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/git'],
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
|
||||
},
|
||||
],
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'header',
|
||||
headerName: 'x-client-id',
|
||||
},
|
||||
],
|
||||
},
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/git'],
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -183,25 +177,23 @@ tap.test('source policy binding can split Gitea path classes before its fallback
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'ai',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'ai',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -234,14 +226,12 @@ tap.test('source policy compiler uses built-in Gitea path class patterns', async
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -274,24 +264,22 @@ tap.test('source policy compiler keeps path-specific variants above fallback var
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -320,12 +308,10 @@ tap.test('source policy compiler fails closed when wildcard binding shadows late
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'public' },
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'public' },
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -334,6 +320,32 @@ tap.test('source policy compiler fails closed when wildcard binding shadows late
|
||||
expect(variants).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('source policy compiler adds terminal deny fallback for private-only bindings', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(variants).toHaveLength(2);
|
||||
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
|
||||
expect(variants[1].id).toEqual('route-1:source:deny-fallback');
|
||||
expect(variants[1].match.clientIp).toBeUndefined();
|
||||
expect(variants[1].action.type).toEqual('socket-handler');
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
expect(variants[1].priority! > makeRoute().priority!).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler fails closed when expansion would exceed route variant caps', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
@@ -349,12 +361,10 @@ tap.test('source policy compiler fails closed when expansion would exceed route
|
||||
),
|
||||
}));
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public', pathPolicies }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public', pathPolicies }],
|
||||
};
|
||||
|
||||
expect(SourcePolicyCompiler.validateSourcePolicyShape(metadata.sourcePolicy)).toContain('compiled route variants');
|
||||
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings)).toContain('compiled route variants');
|
||||
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -372,11 +382,9 @@ tap.test('source policy compiler fails closed when configured bindings cannot co
|
||||
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'empty-ai' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'empty-ai' },
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -385,9 +393,7 @@ tap.test('source policy compiler fails closed when configured bindings cannot co
|
||||
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
undefined,
|
||||
'route-1',
|
||||
@@ -414,19 +420,17 @@ tap.test('source policy compiler keeps generated priorities inside SmartProxy bo
|
||||
}));
|
||||
|
||||
const route = makeRoute();
|
||||
route.priority = 10000;
|
||||
route.priority = 9000;
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
route,
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -437,6 +441,24 @@ tap.test('source policy compiler keeps generated priorities inside SmartProxy bo
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler fails closed when route priority lacks variant headroom', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
|
||||
const route = makeRoute();
|
||||
route.priority = 10000;
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
||||
};
|
||||
|
||||
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings, route)).toContain('priority headroom');
|
||||
expect(SourcePolicyCompiler.compileRoute(route, metadata, resolver, 'route-1')).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager applies source policy as expanded runtime routes', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
@@ -473,12 +495,10 @@ tap.test('RouteConfigManager applies source policy as expanded runtime routes',
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -522,9 +542,7 @@ tap.test('RouteConfigManager does not apply an uncompiled source-policy route',
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -534,6 +552,39 @@ tap.test('RouteConfigManager does not apply an uncompiled source-policy route',
|
||||
expect(appliedRoutes[0].length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager fail-closes managed routes without source bindings', async () => {
|
||||
const appliedRoutes: IRouteConfig[][] = [];
|
||||
const manager = new RouteConfigManager(
|
||||
() => ({
|
||||
updateRoutes: async (routes: IRouteConfig[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
} as any),
|
||||
() => ({ enabled: false }),
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
gatewayClientAppId: 'app-1',
|
||||
externalKey: 'onebox:box-1:app-1:app.example.com',
|
||||
},
|
||||
});
|
||||
|
||||
await manager.applyRoutes();
|
||||
|
||||
expect(appliedRoutes).toHaveLength(1);
|
||||
expect(appliedRoutes[0]).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects wildcard source policy bindings before later bindings', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
@@ -562,23 +613,19 @@ tap.test('RouteConfigManager rejects wildcard source policy bindings before late
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('Wildcard source profile bindings must be last');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('trusted');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
|
||||
@@ -604,23 +651,19 @@ tap.test('RouteConfigManager rejects missing source policy profiles', async () =
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain("Source profile 'missing' not found");
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings).toHaveLength(1);
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
|
||||
@@ -651,26 +694,22 @@ tap.test('RouteConfigManager rejects source profiles without source matches', as
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain("Source profile 'Empty AI' has no source matches");
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings).toHaveLength(1);
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source policies without a final all-source fallback', async () => {
|
||||
tap.test('RouteConfigManager accepts private-only source bindings without public fallback', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
@@ -689,6 +728,8 @@ tap.test('RouteConfigManager rejects source policies without a final all-source
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).persistRoute = async () => undefined;
|
||||
(manager as any).applyRoutes = async () => undefined;
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
@@ -698,23 +739,18 @@ tap.test('RouteConfigManager rejects source policies without a final all-source
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('Source policy must end with an all-source fallback profile');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('public');
|
||||
expect(result.success).toBeTrue();
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source policies with broad port range expansion', async () => {
|
||||
@@ -745,9 +781,7 @@ tap.test('RouteConfigManager rejects source policies with broad port range expan
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -785,23 +819,19 @@ tap.test('RouteConfigManager rejects negative source-policy maxConnections overr
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('maxConnections');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].maxConnections).toBeUndefined();
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].maxConnections).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
|
||||
@@ -827,34 +857,30 @@ tap.test('RouteConfigManager rejects oversized nested source-policy rate limit m
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('rate limit error message');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].rateLimit).toBeUndefined();
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].rateLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
|
||||
@@ -880,36 +906,32 @@ tap.test('RouteConfigManager rejects oversized source policy path patterns', asy
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: Array.from(
|
||||
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
|
||||
(_item, index) => `/too-many-${index}`,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: Array.from(
|
||||
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
|
||||
(_item, index) => `/too-many-${index}`,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('path patterns');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].pathPolicies).toBeUndefined();
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].pathPolicies).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -54,13 +54,13 @@ const makeApiTokenManager = (
|
||||
for (const policyScope of storedToken.policy?.scopes || []) {
|
||||
scopes.add(policyScope);
|
||||
}
|
||||
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
|
||||
const equivalentScopes: Partial<Record<TScope, TScope[]>> = {
|
||||
'gateway-clients:read': ['workhosters:read'],
|
||||
'gateway-clients:write': ['workhosters:write'],
|
||||
'workhosters:read': ['gateway-clients:read'],
|
||||
'workhosters:write': ['gateway-clients:write'],
|
||||
};
|
||||
return scopes.has(scope) || Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
||||
return scopes.has(scope) || Boolean(equivalentScopes[scope]?.some((alias) => scopes.has(alias)));
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -108,6 +108,11 @@ const makeRouteConfigManager = () => {
|
||||
if (!storedRoute) return { success: false, message: 'Route not found' };
|
||||
if (patch.route) {
|
||||
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
|
||||
for (const [key, value] of Object.entries(patch.route)) {
|
||||
if (value === null) {
|
||||
delete (storedRoute.route as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
storedRoute.enabled = patch.enabled;
|
||||
@@ -126,6 +131,20 @@ const makeRouteConfigManager = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const standardSourceProfile: interfaces.data.ISourceProfile = {
|
||||
id: 'standard',
|
||||
name: 'STANDARD',
|
||||
description: 'Standard test profile',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
};
|
||||
|
||||
const makeReferenceResolver = () => ({
|
||||
listProfiles: () => [standardSourceProfile],
|
||||
});
|
||||
|
||||
const setupHandler = (options: {
|
||||
scopes: TScope[];
|
||||
policy?: interfaces.data.IApiTokenPolicy;
|
||||
@@ -146,6 +165,7 @@ const setupHandler = (options: {
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
|
||||
referenceResolver: makeReferenceResolver(),
|
||||
...options.dcRouterRef,
|
||||
},
|
||||
};
|
||||
@@ -159,10 +179,12 @@ tap.test('WorkHosterHandler exposes capabilities and managed domains with workho
|
||||
scopes: ['workhosters:read'],
|
||||
dcRouterRef: {
|
||||
options: {
|
||||
remoteIngressConfig: { enabled: true },
|
||||
dnsScopes: ['example.com'],
|
||||
http3: { enabled: false },
|
||||
},
|
||||
remoteIngressManager: {
|
||||
getHubSettings: () => ({ enabled: true }),
|
||||
},
|
||||
routeConfigManager: {
|
||||
getMergedRoutes: () => ({ routes: [] }),
|
||||
},
|
||||
@@ -244,6 +266,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
expect(createdRoute.createdBy).toEqual('token-user');
|
||||
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
|
||||
expect(createdRoute.metadata).toEqual({
|
||||
sourceBindings: [{ sourceProfileRef: 'standard', sourceProfileName: 'STANDARD' }],
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
@@ -253,6 +276,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
workAppId: 'app-1',
|
||||
externalKey: 'onebox:box-1:app-1:app.example.com',
|
||||
});
|
||||
createdRoute.route.security = { ipAllowList: ['*'] };
|
||||
|
||||
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
@@ -275,6 +299,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
|
||||
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
|
||||
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
|
||||
expect(routeConfig.routes.get('route-1')?.route.security).toBeUndefined();
|
||||
|
||||
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.43.1',
|
||||
version: '14.0.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { logger } from '../logger.js';
|
||||
import { AcmeConfigDoc } from '../db/documents/index.js';
|
||||
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
|
||||
|
||||
/**
|
||||
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
|
||||
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
|
||||
* - `start()` — loads the DB-backed singleton configuration.
|
||||
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
|
||||
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
|
||||
*
|
||||
@@ -20,32 +18,12 @@ import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
|
||||
export class AcmeConfigManager {
|
||||
private cached: IAcmeConfig | null = null;
|
||||
|
||||
constructor(private options: IDcRouterOptions) {}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', 'AcmeConfigManager: starting');
|
||||
let doc = await AcmeConfigDoc.load();
|
||||
const doc = await AcmeConfigDoc.load();
|
||||
|
||||
if (!doc) {
|
||||
// First-boot path: seed from legacy constructor fields if present.
|
||||
const seed = this.deriveSeedFromOptions();
|
||||
if (seed) {
|
||||
doc = await this.createSeedDoc(seed);
|
||||
logger.log(
|
||||
'info',
|
||||
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
|
||||
);
|
||||
} else {
|
||||
logger.log(
|
||||
'info',
|
||||
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
|
||||
);
|
||||
}
|
||||
} else if (this.deriveSeedFromOptions()) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
|
||||
);
|
||||
logger.log('info', 'AcmeConfigManager: no AcmeConfig in DB — ACME disabled until configured via Domains > Certificates > Settings.');
|
||||
}
|
||||
|
||||
this.cached = doc ? this.toPlain(doc) : null;
|
||||
@@ -116,58 +94,6 @@ export class AcmeConfigManager {
|
||||
// Internal helpers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Build a seed object from the legacy constructor fields. Returns null
|
||||
* if the user has not provided any of them.
|
||||
*
|
||||
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
|
||||
* (full form). `smartProxyConfig.acme` wins when both are present.
|
||||
*/
|
||||
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
|
||||
const acme = this.options.smartProxyConfig?.acme;
|
||||
const tls = this.options.tls;
|
||||
|
||||
// Prefer the explicit smartProxyConfig.acme block if present.
|
||||
if (acme?.accountEmail) {
|
||||
return {
|
||||
accountEmail: acme.accountEmail,
|
||||
enabled: acme.enabled !== false,
|
||||
useProduction: acme.useProduction !== false,
|
||||
autoRenew: acme.autoRenew !== false,
|
||||
renewThresholdDays: acme.renewThresholdDays ?? 30,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to the short tls.contactEmail form.
|
||||
if (tls?.contactEmail) {
|
||||
return {
|
||||
accountEmail: tls.contactEmail,
|
||||
enabled: true,
|
||||
useProduction: true,
|
||||
autoRenew: true,
|
||||
renewThresholdDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createSeedDoc(
|
||||
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
|
||||
): Promise<AcmeConfigDoc> {
|
||||
const doc = new AcmeConfigDoc();
|
||||
doc.configId = 'acme-config';
|
||||
doc.accountEmail = seed.accountEmail;
|
||||
doc.enabled = seed.enabled;
|
||||
doc.useProduction = seed.useProduction;
|
||||
doc.autoRenew = seed.autoRenew;
|
||||
doc.renewThresholdDays = seed.renewThresholdDays;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'seed';
|
||||
await doc.save();
|
||||
return doc;
|
||||
}
|
||||
|
||||
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
|
||||
return {
|
||||
accountEmail: doc.accountEmail,
|
||||
|
||||
+603
-145
File diff suppressed because it is too large
Load Diff
@@ -111,13 +111,13 @@ export class ApiTokenManager {
|
||||
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
|
||||
if (scopes.has(scope)) return true;
|
||||
|
||||
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
|
||||
const equivalentScopes: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
|
||||
'gateway-clients:read': ['workhosters:read'],
|
||||
'gateway-clients:write': ['workhosters:write'],
|
||||
'workhosters:read': ['gateway-clients:read'],
|
||||
'workhosters:write': ['gateway-clients:write'],
|
||||
};
|
||||
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
||||
return Boolean(equivalentScopes[scope]?.some((alias) => scopes.has(alias)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
IRouteMetadata,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSourceBinding,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const MAX_INHERITANCE_DEPTH = 5;
|
||||
@@ -288,8 +288,8 @@ export class ReferenceResolver {
|
||||
|
||||
/**
|
||||
* Resolve references for a single route.
|
||||
* Materializes source profile and/or network target into the route's fields.
|
||||
* When a source profile is selected, it owns the route security fully.
|
||||
* Resolves source binding display names and/or network target references.
|
||||
* Source profile security is resolved at apply time by SourcePolicyCompiler.
|
||||
* Returns the resolved route and updated metadata.
|
||||
*/
|
||||
public resolveRoute(
|
||||
@@ -298,27 +298,12 @@ export class ReferenceResolver {
|
||||
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||
|
||||
if (resolvedMetadata.sourcePolicy?.bindings.length) {
|
||||
const resolvedSourcePolicy = this.resolveRouteSourcePolicy(resolvedMetadata.sourcePolicy);
|
||||
if (resolvedSourcePolicy) {
|
||||
resolvedMetadata.sourcePolicy = resolvedSourcePolicy;
|
||||
resolvedMetadata.sourceProfileRef = undefined;
|
||||
resolvedMetadata.sourceProfileName = undefined;
|
||||
if (resolvedMetadata.sourceBindings?.length) {
|
||||
const resolvedSourceBindings = this.resolveRouteSourceBindings(resolvedMetadata.sourceBindings);
|
||||
if (resolvedSourceBindings) {
|
||||
resolvedMetadata.sourceBindings = resolvedSourceBindings;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
}
|
||||
} else if (resolvedMetadata.sourceProfileRef) {
|
||||
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
|
||||
if (resolvedSecurity) {
|
||||
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
|
||||
route = {
|
||||
...route,
|
||||
security: this.cloneSecurityFields(resolvedSecurity),
|
||||
};
|
||||
resolvedMetadata.sourceProfileName = profile?.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
} else {
|
||||
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedMetadata.networkTargetRef) {
|
||||
@@ -387,12 +372,12 @@ export class ReferenceResolver {
|
||||
// Private: source profile resolution with inheritance
|
||||
// =========================================================================
|
||||
|
||||
private resolveRouteSourcePolicy(sourcePolicy: IRouteSourcePolicy): IRouteSourcePolicy | undefined {
|
||||
const bindings = sourcePolicy.bindings
|
||||
private resolveRouteSourceBindings(sourceBindings: IRouteSourceBinding[]): IRouteSourceBinding[] | undefined {
|
||||
const bindings = sourceBindings
|
||||
.map((binding) => {
|
||||
const profile = this.profiles.get(binding.sourceProfileRef);
|
||||
if (!profile) {
|
||||
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source policy resolution`);
|
||||
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source binding resolution`);
|
||||
return binding;
|
||||
}
|
||||
return {
|
||||
@@ -402,7 +387,7 @@ export class ReferenceResolver {
|
||||
})
|
||||
.filter((binding) => binding.sourceProfileRef);
|
||||
|
||||
return bindings.length > 0 ? { bindings } : undefined;
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
private metadataUsesSourceProfile(metadata: IRouteMetadata | undefined, profileId: string): boolean {
|
||||
@@ -411,10 +396,7 @@ export class ReferenceResolver {
|
||||
|
||||
private getSourceProfileRefsFromMetadata(metadata: IRouteMetadata | undefined): string[] {
|
||||
const refs = new Set<string>();
|
||||
if (metadata?.sourceProfileRef) {
|
||||
refs.add(metadata.sourceProfileRef);
|
||||
}
|
||||
for (const binding of metadata?.sourcePolicy?.bindings || []) {
|
||||
for (const binding of metadata?.sourceBindings || []) {
|
||||
if (binding.sourceProfileRef) {
|
||||
refs.add(binding.sourceProfileRef);
|
||||
}
|
||||
@@ -623,22 +605,16 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
private clearSourceProfileFromMetadata(metadata: IRouteMetadata, profileId: string): IRouteMetadata {
|
||||
const sourcePolicy = metadata.sourcePolicy?.bindings?.length
|
||||
? {
|
||||
bindings: metadata.sourcePolicy.bindings.filter(
|
||||
(binding) => binding.sourceProfileRef !== profileId,
|
||||
),
|
||||
}
|
||||
const sourceBindings = metadata.sourceBindings?.length
|
||||
? metadata.sourceBindings.filter((binding) => binding.sourceProfileRef !== profileId)
|
||||
: undefined;
|
||||
|
||||
const nextMetadata: IRouteMetadata = {
|
||||
...metadata,
|
||||
sourceProfileRef: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileRef,
|
||||
sourceProfileName: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileName,
|
||||
sourcePolicy: sourcePolicy?.bindings.length ? sourcePolicy : undefined,
|
||||
sourceBindings: sourceBindings?.length ? sourceBindings : undefined,
|
||||
};
|
||||
|
||||
if (!nextMetadata.sourceProfileRef && !nextMetadata.sourcePolicy && !nextMetadata.networkTargetRef) {
|
||||
if (!nextMetadata.sourceBindings && !nextMetadata.networkTargetRef) {
|
||||
nextMetadata.lastResolvedAt = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
IRoutePathPolicyBinding,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSourceBinding,
|
||||
IRouteSecurity,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
@@ -68,6 +68,7 @@ export class RouteConfigManager {
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
||||
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
|
||||
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
||||
private applyInboundProxyPolicies?: (routes: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
|
||||
) {}
|
||||
|
||||
/** Expose routes map for reference resolution lookups. */
|
||||
@@ -85,6 +86,10 @@ export class RouteConfigManager {
|
||||
this.getVpnClientAccessForRoute = resolver;
|
||||
}
|
||||
|
||||
public async runExclusiveRouteUpdate<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await this.routeUpdateMutex.runExclusive(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted routes, seed serializable config/email/dns routes,
|
||||
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||
@@ -142,9 +147,9 @@ export class RouteConfigManager {
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(metadata?.sourcePolicy);
|
||||
if (sourcePolicyPayloadError) {
|
||||
throw new Error(sourcePolicyPayloadError);
|
||||
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(metadata?.sourceBindings);
|
||||
if (sourceBindingsPayloadError) {
|
||||
throw new Error(sourceBindingsPayloadError);
|
||||
}
|
||||
|
||||
// Ensure route has a name
|
||||
@@ -159,9 +164,9 @@ export class RouteConfigManager {
|
||||
route = resolved.route;
|
||||
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
const sourcePolicyValidationError = this.validateSourcePolicy(resolvedMetadata?.sourcePolicy, route);
|
||||
if (sourcePolicyValidationError) {
|
||||
throw new Error(sourcePolicyValidationError);
|
||||
const sourceBindingsValidationError = this.validateSourceBindings(resolvedMetadata?.sourceBindings, route);
|
||||
if (sourceBindingsValidationError) {
|
||||
throw new Error(sourceBindingsValidationError);
|
||||
}
|
||||
|
||||
const stored: IRoute = {
|
||||
@@ -193,12 +198,11 @@ export class RouteConfigManager {
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(patch.metadata?.sourcePolicy);
|
||||
if (sourcePolicyPayloadError) {
|
||||
return { success: false, message: sourcePolicyPayloadError };
|
||||
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(patch.metadata?.sourceBindings);
|
||||
if (sourceBindingsPayloadError) {
|
||||
return { success: false, message: sourceBindingsPayloadError };
|
||||
}
|
||||
|
||||
const previousSourceProfileRef = stored.metadata?.sourceProfileRef;
|
||||
const previousRoute = structuredClone(stored.route);
|
||||
const previousMetadata = structuredClone(stored.metadata);
|
||||
const previousEnabled = stored.enabled;
|
||||
@@ -244,13 +248,6 @@ export class RouteConfigManager {
|
||||
...stored.metadata,
|
||||
...patch.metadata,
|
||||
});
|
||||
if (
|
||||
previousSourceProfileRef
|
||||
&& !stored.metadata?.sourceProfileRef
|
||||
&& !patch.route?.security
|
||||
) {
|
||||
delete stored.route.security;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve if metadata refs exist and resolver is available
|
||||
@@ -260,12 +257,12 @@ export class RouteConfigManager {
|
||||
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
|
||||
const sourcePolicyValidationError = this.validateSourcePolicy(stored.metadata?.sourcePolicy, stored.route);
|
||||
if (sourcePolicyValidationError) {
|
||||
const sourceBindingsValidationError = this.validateSourceBindings(stored.metadata?.sourceBindings, stored.route);
|
||||
if (sourceBindingsValidationError) {
|
||||
stored.route = previousRoute;
|
||||
stored.metadata = previousMetadata;
|
||||
stored.enabled = previousEnabled;
|
||||
return { success: false, message: sourcePolicyValidationError };
|
||||
return { success: false, message: sourceBindingsValidationError };
|
||||
}
|
||||
|
||||
stored.updatedAt = Date.now();
|
||||
@@ -487,10 +484,8 @@ export class RouteConfigManager {
|
||||
};
|
||||
|
||||
const normalized: IRouteMetadata = {
|
||||
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
|
||||
sourcePolicy: this.normalizeSourcePolicy(metadata.sourcePolicy),
|
||||
sourceBindings: this.normalizeSourceBindings(metadata.sourceBindings),
|
||||
networkTargetRef: normalizeString(metadata.networkTargetRef),
|
||||
sourceProfileName: normalizeString(metadata.sourceProfileName),
|
||||
networkTargetName: normalizeString(metadata.networkTargetName),
|
||||
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
||||
? metadata.lastResolvedAt
|
||||
@@ -511,13 +506,10 @@ export class RouteConfigManager {
|
||||
externalKey: normalizeString(metadata.externalKey),
|
||||
};
|
||||
|
||||
if (!normalized.sourceProfileRef) {
|
||||
normalized.sourceProfileName = undefined;
|
||||
}
|
||||
if (!normalized.networkTargetRef) {
|
||||
normalized.networkTargetName = undefined;
|
||||
}
|
||||
if (!normalized.sourceProfileRef && !normalized.sourcePolicy && !normalized.networkTargetRef) {
|
||||
if (!normalized.sourceBindings && !normalized.networkTargetRef) {
|
||||
normalized.lastResolvedAt = undefined;
|
||||
}
|
||||
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
||||
@@ -542,14 +534,13 @@ export class RouteConfigManager {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeSourcePolicy(sourcePolicy?: Partial<IRouteSourcePolicy>): IRouteSourcePolicy | undefined {
|
||||
const bindings = sourcePolicy?.bindings;
|
||||
if (!Array.isArray(bindings)) {
|
||||
private normalizeSourceBindings(sourceBindings?: Partial<IRouteSourceBinding>[]): IRouteSourceBinding[] | undefined {
|
||||
if (!Array.isArray(sourceBindings)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedBindings: IRouteSourcePolicy['bindings'] = [];
|
||||
for (const binding of bindings) {
|
||||
const normalizedBindings: IRouteSourceBinding[] = [];
|
||||
for (const binding of sourceBindings) {
|
||||
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
|
||||
? binding.sourceProfileRef.trim()
|
||||
: '';
|
||||
@@ -583,7 +574,7 @@ export class RouteConfigManager {
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedBindings.length > 0 ? { bindings: normalizedBindings } : undefined;
|
||||
return normalizedBindings.length > 0 ? normalizedBindings : undefined;
|
||||
}
|
||||
|
||||
private normalizePathPolicies(
|
||||
@@ -631,15 +622,15 @@ export class RouteConfigManager {
|
||||
return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined;
|
||||
}
|
||||
|
||||
private validateSourcePolicy(
|
||||
sourcePolicy: IRouteSourcePolicy | undefined,
|
||||
private validateSourceBindings(
|
||||
sourceBindings: IRouteSourceBinding[] | undefined,
|
||||
route: IDcRouterRouteConfig,
|
||||
): string | undefined {
|
||||
const shapeError = SourcePolicyCompiler.validateSourcePolicyShape(sourcePolicy, route);
|
||||
const shapeError = SourcePolicyCompiler.validateSourceBindingsShape(sourceBindings, route);
|
||||
if (shapeError) {
|
||||
return shapeError;
|
||||
}
|
||||
return SourcePolicyCompiler.validateResolvedSourcePolicy(sourcePolicy, this.referenceResolver);
|
||||
return SourcePolicyCompiler.validateResolvedSourceBindings(sourceBindings, this.referenceResolver);
|
||||
}
|
||||
|
||||
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
|
||||
@@ -724,12 +715,15 @@ export class RouteConfigManager {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
||||
let enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
if (this.applyInboundProxyPolicies) {
|
||||
enabledRoutes = this.applyInboundProxyPolicies(enabledRoutes);
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
@@ -756,14 +750,29 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
|
||||
if (this.isManagedAccessRoute(storedRoute) && !storedRoute.metadata?.sourceBindings?.length) {
|
||||
return [];
|
||||
}
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
const sourcePolicyRoutes = SourcePolicyCompiler.compileRoute(
|
||||
const sourceBoundRoutes = SourcePolicyCompiler.compileRoute(
|
||||
hydratedRoute || storedRoute.route,
|
||||
storedRoute.metadata,
|
||||
this.referenceResolver,
|
||||
storedRoute.id,
|
||||
);
|
||||
return sourcePolicyRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
|
||||
return sourceBoundRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
|
||||
}
|
||||
|
||||
private isManagedAccessRoute(storedRoute: IRoute): boolean {
|
||||
const metadata = storedRoute.metadata;
|
||||
if (storedRoute.origin !== 'api' || !metadata) {
|
||||
return false;
|
||||
}
|
||||
return metadata.ownerType === 'gatewayClient'
|
||||
|| metadata.ownerType === 'workhoster'
|
||||
|| Boolean(metadata.gatewayClientId)
|
||||
|| Boolean(metadata.workHosterId)
|
||||
|| Boolean(metadata.externalKey);
|
||||
}
|
||||
|
||||
private prepareRouteForApply(
|
||||
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
IRoutePathPolicyBinding,
|
||||
IRouteMetadata,
|
||||
IRouteSecurity,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSourcePolicyBinding,
|
||||
IRouteSourceBinding,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
@@ -37,22 +36,23 @@ export class SourcePolicyCompiler {
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig[] {
|
||||
const bindings = metadata?.sourcePolicy?.bindings || [];
|
||||
const bindings = metadata?.sourceBindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return [route];
|
||||
}
|
||||
if (this.validateSourcePolicyShape(metadata?.sourcePolicy, route)) {
|
||||
if (this.validateSourceBindingsShape(bindings, route)) {
|
||||
return [];
|
||||
}
|
||||
if (!referenceResolver) {
|
||||
return [];
|
||||
}
|
||||
if (this.validateResolvedSourcePolicy(metadata?.sourcePolicy, referenceResolver)) {
|
||||
if (this.validateResolvedSourceBindings(bindings, referenceResolver)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const compiledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const basePriority = route.priority ?? 0;
|
||||
let hasAllSourcesBinding = false;
|
||||
|
||||
bindings.forEach((binding, index) => {
|
||||
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
|
||||
@@ -65,6 +65,9 @@ export class SourcePolicyCompiler {
|
||||
if (sourceMatches.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.matchesAllSources(sourceMatches)) {
|
||||
hasAllSourcesBinding = true;
|
||||
}
|
||||
const sourcePriority = this.calculateSourcePriority(basePriority, index, bindings.length);
|
||||
const sourceMatch = this.matchesAllSources(sourceMatches)
|
||||
? { ...route.match }
|
||||
@@ -140,39 +143,43 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
});
|
||||
|
||||
if (compiledRoutes.length > 0 && !hasAllSourcesBinding) {
|
||||
compiledRoutes.push(this.buildDenyFallbackRoute(route, basePriority, routeId));
|
||||
}
|
||||
|
||||
return this.applyIntegerPriorities(compiledRoutes, basePriority);
|
||||
}
|
||||
|
||||
public static validateSourcePolicyPayload(sourcePolicy?: Partial<IRouteSourcePolicy>): string | undefined {
|
||||
if (!sourcePolicy) {
|
||||
public static validateSourceBindingsPayload(sourceBindings?: Partial<IRouteSourceBinding>[]): string | undefined {
|
||||
if (sourceBindings === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(sourcePolicy.bindings)) {
|
||||
return 'Source policy bindings must be an array';
|
||||
if (!Array.isArray(sourceBindings)) {
|
||||
return 'Source bindings must be an array';
|
||||
}
|
||||
if (sourcePolicy.bindings.length === 0) {
|
||||
if (sourceBindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (sourcePolicy.bindings.length > sourcePolicyLimits.maxBindings) {
|
||||
if (sourceBindings.length > sourcePolicyLimits.maxBindings) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
|
||||
}
|
||||
|
||||
const validClasses = new Set<string>(routePathClasses);
|
||||
for (const binding of sourcePolicy.bindings) {
|
||||
for (const binding of sourceBindings) {
|
||||
if (!binding || typeof binding !== 'object') {
|
||||
return 'Source policy binding must be an object';
|
||||
return 'Source binding must be an object';
|
||||
}
|
||||
if (typeof binding.sourceProfileRef !== 'string') {
|
||||
return 'Source policy binding requires a source profile';
|
||||
return 'Source binding requires a source profile';
|
||||
}
|
||||
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
|
||||
return `Source policy source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
|
||||
return `Source binding source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
|
||||
}
|
||||
if (binding.sourceProfileRef.trim().length === 0) {
|
||||
return 'Source policy binding requires a source profile';
|
||||
return 'Source binding requires a source profile';
|
||||
}
|
||||
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
|
||||
return `Source policy binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
|
||||
return `Source binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
|
||||
}
|
||||
if (typeof binding.maxConnections === 'number' && binding.maxConnections < 0) {
|
||||
return 'Source policy maxConnections must be non-negative';
|
||||
@@ -268,14 +275,21 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
|
||||
public static validateSourcePolicyShape(
|
||||
sourcePolicy?: IRouteSourcePolicy,
|
||||
sourceBindings?: IRouteSourceBinding[],
|
||||
route?: plugins.smartproxy.IRouteConfig,
|
||||
): string | undefined {
|
||||
const payloadError = this.validateSourcePolicyPayload(sourcePolicy);
|
||||
return this.validateSourceBindingsShape(sourceBindings, route);
|
||||
}
|
||||
|
||||
public static validateSourceBindingsShape(
|
||||
sourceBindings?: IRouteSourceBinding[],
|
||||
route?: plugins.smartproxy.IRouteConfig,
|
||||
): string | undefined {
|
||||
const payloadError = this.validateSourceBindingsPayload(sourceBindings);
|
||||
if (payloadError) {
|
||||
return payloadError;
|
||||
}
|
||||
const bindings = sourcePolicy?.bindings || [];
|
||||
const bindings = sourceBindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -310,19 +324,36 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
}
|
||||
|
||||
// Private-only source bindings add one terminal deny route to prevent fall-through
|
||||
// to broader routes with the same host/path/port scope.
|
||||
estimatedCompiledRoutes++;
|
||||
|
||||
const expandedPortCount = route ? this.getExpandedPortCount(route.match?.ports) : 1;
|
||||
if (estimatedCompiledRoutes * expandedPortCount > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route-port variants`;
|
||||
}
|
||||
if (route && typeof route.priority === 'number' && Number.isFinite(route.priority)) {
|
||||
const integerBasePriority = Math.trunc(this.clampPriority(route.priority));
|
||||
if (integerBasePriority + estimatedCompiledRoutes > MAX_ROUTE_PRIORITY) {
|
||||
return `Source policy route priority leaves no priority headroom for ${estimatedCompiledRoutes} compiled variants`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static validateResolvedSourcePolicy(
|
||||
sourcePolicy: IRouteSourcePolicy | undefined,
|
||||
sourceBindings: IRouteSourceBinding[] | undefined,
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
): string | undefined {
|
||||
const bindings = sourcePolicy?.bindings || [];
|
||||
return this.validateResolvedSourceBindings(sourceBindings, referenceResolver);
|
||||
}
|
||||
|
||||
public static validateResolvedSourceBindings(
|
||||
sourceBindings: IRouteSourceBinding[] | undefined,
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
): string | undefined {
|
||||
const bindings = sourceBindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -346,10 +377,7 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
const matchesAllSources = this.matchesAllSources(sourceMatches);
|
||||
if (matchesAllSources && index < bindings.length - 1) {
|
||||
return 'Wildcard source profile bindings must be last in a source policy';
|
||||
}
|
||||
if (index === bindings.length - 1 && !matchesAllSources) {
|
||||
return 'Source policy must end with an all-source fallback profile';
|
||||
return 'Wildcard source profile bindings must be last in source bindings';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +389,7 @@ export class SourcePolicyCompiler {
|
||||
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
|
||||
profileName: string;
|
||||
profileSecurity: IRouteSecurity;
|
||||
binding: IRouteSourcePolicyBinding;
|
||||
binding: IRouteSourceBinding;
|
||||
pathPolicy?: IRoutePathPolicyBinding;
|
||||
pathPattern?: string;
|
||||
sourcePriority: number;
|
||||
@@ -414,6 +442,63 @@ export class SourcePolicyCompiler {
|
||||
};
|
||||
}
|
||||
|
||||
private static buildDenyFallbackRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
basePriority: number,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const routeKey = route.id || routeId || route.name || 'route';
|
||||
return {
|
||||
...route,
|
||||
id: `${routeKey}:source:deny-fallback`,
|
||||
name: `${route.name || routeKey}:source:deny-fallback`,
|
||||
match: { ...route.match },
|
||||
priority: this.clampPriority(basePriority - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND),
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: (socket) => this.denySocket(socket),
|
||||
},
|
||||
security: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static denySocket(socket: plugins.net.Socket): void {
|
||||
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.removeListener('data', handleData);
|
||||
socket.removeListener('error', cleanup);
|
||||
socket.removeListener('close', cleanup);
|
||||
};
|
||||
|
||||
const handleData = (chunk: string | Uint8Array) => {
|
||||
cleanup();
|
||||
if (this.looksLikeHttpRequest(chunk)) {
|
||||
socket.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 9\r\nConnection: close\r\n\r\nForbidden');
|
||||
return;
|
||||
}
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
socket.destroy();
|
||||
}, 2000) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
timeout.unref?.();
|
||||
|
||||
socket.once('data', handleData);
|
||||
socket.once('error', cleanup);
|
||||
socket.once('close', cleanup);
|
||||
}
|
||||
|
||||
private static looksLikeHttpRequest(chunk: string | Uint8Array): boolean {
|
||||
const prefix = typeof chunk === 'string'
|
||||
? chunk.slice(0, 16)
|
||||
: String.fromCharCode(...chunk.subarray(0, 16));
|
||||
return /^(GET|POST|HEAD|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s/.test(prefix)
|
||||
|| prefix.startsWith('PRI * HTTP/2.0');
|
||||
}
|
||||
|
||||
private static getPathPatterns(pathPolicy: IRoutePathPolicyBinding): string[] {
|
||||
const patterns: string[] = pathPolicy.pathPatterns?.length
|
||||
? pathPolicy.pathPatterns
|
||||
@@ -469,8 +554,8 @@ export class SourcePolicyCompiler {
|
||||
}))
|
||||
.sort((a, b) => (b.priority - a.priority) || (a.originalIndex - b.originalIndex));
|
||||
const topPriority = Math.trunc(this.clampPriority(
|
||||
basePriority + routes.length - 1,
|
||||
MIN_ROUTE_PRIORITY + routes.length - 1,
|
||||
basePriority + routes.length,
|
||||
MIN_ROUTE_PRIORITY + routes.length,
|
||||
MAX_ROUTE_PRIORITY,
|
||||
));
|
||||
const integerPriorities = new Map<number, number>();
|
||||
@@ -589,7 +674,7 @@ export class SourcePolicyCompiler {
|
||||
private static buildBindingSecurity(
|
||||
routeSecurity: IRouteSecurity | undefined,
|
||||
profileSecurity: IRouteSecurity,
|
||||
binding: IRouteSourcePolicyBinding,
|
||||
binding: IRouteSourceBinding,
|
||||
pathPolicy?: IRoutePathPolicyBinding,
|
||||
): IRouteSecurity | undefined {
|
||||
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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
|
||||
*
|
||||
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
|
||||
* since decorators on abstract classes don't propagate correctly.
|
||||
*/
|
||||
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
|
||||
/**
|
||||
* Timestamp when the document was created
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Timestamp when the document expires and should be cleaned up
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public expiresAt!: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last access (for LRU-style eviction if needed)
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
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;
|
||||
@@ -8,9 +8,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
* keyed on the fixed `configId = 'acme-config'` following the
|
||||
* `VpnServerKeysDoc` pattern.
|
||||
*
|
||||
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
|
||||
* constructor fields. Managed via the OpsServer UI at
|
||||
* **Domains > Certificates > Settings**.
|
||||
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const TTL = plugins.smartdata.smartdataTtlValues;
|
||||
|
||||
/**
|
||||
* Email status in the cache
|
||||
*/
|
||||
@@ -19,17 +20,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
* and maintaining email history for the configured TTL period.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
// TTL fields from base class (decorators required on concrete class)
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<CachedEmail> {
|
||||
/**
|
||||
* Unique identifier for this email
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const TTL = plugins.smartdata.smartdataTtlValues;
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
@@ -29,17 +30,7 @@ export interface IIPReputationData {
|
||||
* external API calls. Default TTL is 24 hours.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
// TTL fields from base class (decorators required on concrete class)
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
export class CachedIPReputation extends plugins.smartdata.SmartdataCachedDocument<CachedIPReputation> {
|
||||
/**
|
||||
* IP address (unique identifier)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IEmailPortConfig } from '../../../ts_interfaces/data/email-settings.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class EmailServerSettingsDoc extends plugins.smartdata.SmartDataDbDoc<EmailServerSettingsDoc, EmailServerSettingsDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public settingsId: string = 'email-server-settings';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public emailConfig?: IUnifiedEmailServerOptions;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public emailPortConfig?: IEmailPortConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedBy: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async load(): Promise<EmailServerSettingsDoc | null> {
|
||||
return await EmailServerSettingsDoc.getInstance({ settingsId: 'email-server-settings' });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<EmailServerSettingsDoc[]> {
|
||||
return await EmailServerSettingsDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,15 @@ export class RemoteIngressHubSettingsDoc extends plugins.smartdata.SmartDataDbDo
|
||||
@plugins.smartdata.svDb()
|
||||
public settingsId: string = 'remote-ingress-hub-settings';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public tunnelPort?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public hubDomain?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public performance?: IRemoteIngressPerformanceConfig;
|
||||
|
||||
|
||||
@@ -40,3 +40,4 @@ export * from './classes.acme-config.doc.js';
|
||||
|
||||
// Email domain management
|
||||
export * from './classes.email-domain.doc.js';
|
||||
export * from './classes.email-server-settings.doc.js';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Unified database manager
|
||||
export * from './classes.dcrouter-db.js';
|
||||
|
||||
// TTL base class and constants
|
||||
export * from './classes.cached.document.js';
|
||||
|
||||
// Cache cleaner
|
||||
export * from './classes.cache.cleaner.js';
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Load Domain/DnsRecord docs from the DB on start
|
||||
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
|
||||
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
|
||||
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
|
||||
* smartdns, provider domains hit the provider API)
|
||||
@@ -53,13 +52,8 @@ export class DnsManager {
|
||||
// Lifecycle
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Called from DcRouter after DcRouterDb is up. Performs first-boot seeding
|
||||
* from legacy constructor config if (and only if) the DB is empty.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', 'DnsManager: starting');
|
||||
await this.seedFromConstructorConfigIfEmpty();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
@@ -77,103 +71,6 @@ export class DnsManager {
|
||||
await this.applyDcrouterDomainsToDnsServer();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// First-boot seeding
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
|
||||
* seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with
|
||||
* local (`record.source: 'local'`) records. On subsequent boots (DB has
|
||||
* entries), constructor config is ignored with a warning.
|
||||
*/
|
||||
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
|
||||
const existingDomains = await DomainDoc.findAll();
|
||||
const hasLegacyConfig =
|
||||
(this.options.dnsScopes && this.options.dnsScopes.length > 0) ||
|
||||
(this.options.dnsRecords && this.options.dnsRecords.length > 0);
|
||||
|
||||
if (existingDomains.length > 0) {
|
||||
if (hasLegacyConfig) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
|
||||
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasLegacyConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config');
|
||||
|
||||
const now = Date.now();
|
||||
const seededDomains = new Map<string, DomainDoc>();
|
||||
|
||||
// Create one DomainDoc per dnsScope (these are the authoritative zones)
|
||||
for (const scope of this.options.dnsScopes ?? []) {
|
||||
const domain = new DomainDoc();
|
||||
domain.id = plugins.uuid.v4();
|
||||
domain.name = scope.toLowerCase();
|
||||
domain.source = 'dcrouter';
|
||||
domain.authoritative = true;
|
||||
domain.createdAt = now;
|
||||
domain.updatedAt = now;
|
||||
domain.createdBy = 'seed';
|
||||
await domain.save();
|
||||
seededDomains.set(domain.name, domain);
|
||||
logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`);
|
||||
}
|
||||
|
||||
// Map each legacy dnsRecord to its parent DomainDoc
|
||||
for (const rec of this.options.dnsRecords ?? []) {
|
||||
const parent = this.findParentDomain(rec.name, seededDomains);
|
||||
if (!parent) {
|
||||
logger.log(
|
||||
'warn',
|
||||
`DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const record = new DnsRecordDoc();
|
||||
record.id = plugins.uuid.v4();
|
||||
record.domainId = parent.id;
|
||||
record.name = rec.name.toLowerCase();
|
||||
record.type = rec.type as TDnsRecordType;
|
||||
record.value = rec.value;
|
||||
record.ttl = rec.ttl ?? 300;
|
||||
record.source = 'local';
|
||||
record.createdAt = now;
|
||||
record.updatedAt = now;
|
||||
record.createdBy = 'seed';
|
||||
await record.save();
|
||||
}
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`,
|
||||
);
|
||||
}
|
||||
|
||||
private findParentDomain(
|
||||
recordName: string,
|
||||
domains: Map<string, DomainDoc>,
|
||||
): DomainDoc | null {
|
||||
const lower = recordName.toLowerCase().replace(/^\*\./, '');
|
||||
let candidate: DomainDoc | null = null;
|
||||
for (const [name, doc] of domains) {
|
||||
if (lower === name || lower.endsWith(`.${name}`)) {
|
||||
if (!candidate || name.length > candidate.name.length) {
|
||||
candidate = doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DcRouter-hosted domain DnsServer wiring
|
||||
// ==========================================================================
|
||||
|
||||
@@ -17,11 +17,15 @@ import { buildEmailDnsRecords } from './email-dns-records.js';
|
||||
*/
|
||||
export class EmailDomainManager {
|
||||
private dcRouter: any; // DcRouter — avoids circular import
|
||||
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||
private baseEmailDomains: IEmailDomainConfig[] = [];
|
||||
|
||||
constructor(dcRouterRef: any) {
|
||||
this.dcRouter = dcRouterRef;
|
||||
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
this.setBaseEmailDomains(this.dcRouter.options?.emailConfig?.domains as IEmailDomainConfig[] | undefined);
|
||||
}
|
||||
|
||||
public setBaseEmailDomains(domains: IEmailDomainConfig[] | undefined): void {
|
||||
this.baseEmailDomains = (domains || [])
|
||||
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
import { EmailServerSettingsDoc } from '../db/index.js';
|
||||
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||
import type {
|
||||
IEmailPortConfig,
|
||||
IEmailServerSettings,
|
||||
TEmailServerSettingsUpdate,
|
||||
} from '../../ts_interfaces/data/email-settings.js';
|
||||
|
||||
const defaultEmailPorts = [25, 587, 465];
|
||||
|
||||
function clonePlain<T>(value: T | undefined): T | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function hasOwn(objectArg: object, keyArg: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
|
||||
}
|
||||
|
||||
export class EmailSettingsManager {
|
||||
private cachedEmailConfig?: IUnifiedEmailServerOptions;
|
||||
private cachedEmailPortConfig?: IEmailPortConfig;
|
||||
private enabled = false;
|
||||
private updatedAt = 0;
|
||||
private updatedBy = 'default';
|
||||
|
||||
constructor(private options: IDcRouterOptions) {}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
let doc = await EmailServerSettingsDoc.load();
|
||||
|
||||
if (!doc) {
|
||||
doc = new EmailServerSettingsDoc();
|
||||
doc.settingsId = 'email-server-settings';
|
||||
doc.enabled = false;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'default';
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
this.loadFromDoc(doc);
|
||||
this.applyToRuntimeOptions();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.cachedEmailConfig = undefined;
|
||||
this.cachedEmailPortConfig = undefined;
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
public isEnabled(): boolean {
|
||||
return this.enabled && Boolean(this.cachedEmailConfig);
|
||||
}
|
||||
|
||||
public getEmailConfig(): IUnifiedEmailServerOptions | undefined {
|
||||
return this.isEnabled() ? clonePlain(this.cachedEmailConfig) : undefined;
|
||||
}
|
||||
|
||||
public getEmailPortConfig(): IEmailPortConfig | undefined {
|
||||
return this.isEnabled() ? clonePlain(this.cachedEmailPortConfig) : undefined;
|
||||
}
|
||||
|
||||
public getPublicSettings(): IEmailServerSettings {
|
||||
const emailConfig = this.cachedEmailConfig;
|
||||
const emailPortConfig = this.cachedEmailPortConfig;
|
||||
return {
|
||||
enabled: this.isEnabled(),
|
||||
hostname: emailConfig?.hostname || null,
|
||||
ports: [...(emailConfig?.ports || [])],
|
||||
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
|
||||
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
|
||||
maxMessageSize: emailConfig?.maxMessageSize ?? null,
|
||||
domainCount: emailConfig?.domains?.length || 0,
|
||||
routeCount: emailConfig?.routes?.length || 0,
|
||||
authUserCount: emailConfig?.auth?.users?.length || 0,
|
||||
updatedAt: this.updatedAt,
|
||||
updatedBy: this.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
public async updateSettings(
|
||||
updates: TEmailServerSettingsUpdate,
|
||||
updatedBy: string,
|
||||
): Promise<IEmailServerSettings> {
|
||||
let doc = await EmailServerSettingsDoc.load();
|
||||
if (!doc) {
|
||||
doc = new EmailServerSettingsDoc();
|
||||
doc.settingsId = 'email-server-settings';
|
||||
}
|
||||
|
||||
const nextEnabled = hasOwn(updates, 'enabled') ? Boolean(updates.enabled) : doc.enabled;
|
||||
const nextEmailConfig = this.patchEmailConfig(doc.emailConfig, updates, nextEnabled);
|
||||
const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig, updates);
|
||||
|
||||
doc.enabled = nextEnabled;
|
||||
doc.emailConfig = nextEmailConfig;
|
||||
doc.emailPortConfig = nextEmailPortConfig;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = updatedBy;
|
||||
await doc.save();
|
||||
|
||||
this.loadFromDoc(doc);
|
||||
this.applyToRuntimeOptions();
|
||||
return this.getPublicSettings();
|
||||
}
|
||||
|
||||
private loadFromDoc(doc: EmailServerSettingsDoc): void {
|
||||
this.enabled = doc.enabled;
|
||||
this.cachedEmailConfig = clonePlain(doc.emailConfig);
|
||||
this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig);
|
||||
this.updatedAt = doc.updatedAt;
|
||||
this.updatedBy = doc.updatedBy;
|
||||
}
|
||||
|
||||
private applyToRuntimeOptions(): void {
|
||||
this.options.emailConfig = this.getEmailConfig();
|
||||
this.options.emailPortConfig = this.getEmailPortConfig();
|
||||
}
|
||||
|
||||
private patchEmailConfig(
|
||||
existingConfig: IUnifiedEmailServerOptions | undefined,
|
||||
updates: TEmailServerSettingsUpdate,
|
||||
nextEnabled: boolean,
|
||||
): IUnifiedEmailServerOptions | undefined {
|
||||
const nextConfig: IUnifiedEmailServerOptions | undefined = clonePlain(existingConfig) || (nextEnabled ? {
|
||||
hostname: 'localhost',
|
||||
ports: [...defaultEmailPorts],
|
||||
domains: [],
|
||||
routes: [],
|
||||
} : undefined);
|
||||
|
||||
if (!nextConfig) return undefined;
|
||||
|
||||
if (hasOwn(updates, 'hostname')) {
|
||||
const hostname = updates.hostname?.trim() || '';
|
||||
if (nextEnabled && !hostname) {
|
||||
throw new Error('Email hostname is required when email is enabled');
|
||||
}
|
||||
nextConfig.hostname = hostname || nextConfig.hostname;
|
||||
}
|
||||
|
||||
if (hasOwn(updates, 'ports')) {
|
||||
nextConfig.ports = this.normalizePorts(updates.ports || []);
|
||||
}
|
||||
|
||||
if (hasOwn(updates, 'maxMessageSize')) {
|
||||
if (updates.maxMessageSize === null || updates.maxMessageSize === undefined) {
|
||||
delete nextConfig.maxMessageSize;
|
||||
} else {
|
||||
const maxMessageSize = Number(updates.maxMessageSize);
|
||||
if (!Number.isInteger(maxMessageSize) || maxMessageSize <= 0) {
|
||||
throw new Error('maxMessageSize must be a positive integer');
|
||||
}
|
||||
nextConfig.maxMessageSize = maxMessageSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextEnabled) {
|
||||
if (!nextConfig.hostname?.trim()) {
|
||||
throw new Error('Email hostname is required when email is enabled');
|
||||
}
|
||||
nextConfig.ports = this.normalizePorts(nextConfig.ports || []);
|
||||
}
|
||||
|
||||
nextConfig.domains = nextConfig.domains || [];
|
||||
nextConfig.routes = nextConfig.routes || [];
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
private patchEmailPortConfig(
|
||||
existingPortConfig: IEmailPortConfig | undefined,
|
||||
updates: TEmailServerSettingsUpdate,
|
||||
): IEmailPortConfig | undefined {
|
||||
const nextPortConfig: IEmailPortConfig = clonePlain(existingPortConfig) || {};
|
||||
if (hasOwn(updates, 'portMapping')) {
|
||||
if (updates.portMapping === null) {
|
||||
delete nextPortConfig.portMapping;
|
||||
} else {
|
||||
nextPortConfig.portMapping = this.normalizePortMapping(updates.portMapping || {});
|
||||
}
|
||||
}
|
||||
if (hasOwn(updates, 'receivedEmailsPath')) {
|
||||
const receivedEmailsPath = updates.receivedEmailsPath?.trim() || '';
|
||||
if (receivedEmailsPath) {
|
||||
nextPortConfig.receivedEmailsPath = receivedEmailsPath;
|
||||
} else {
|
||||
delete nextPortConfig.receivedEmailsPath;
|
||||
}
|
||||
}
|
||||
return Object.keys(nextPortConfig).length > 0 ? nextPortConfig : undefined;
|
||||
}
|
||||
|
||||
private normalizePorts(ports: number[]): number[] {
|
||||
const normalized = [...new Set(ports.map((port) => Number(port)))];
|
||||
if (normalized.length === 0) {
|
||||
throw new Error('At least one email port is required when email is enabled');
|
||||
}
|
||||
for (const port of normalized) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid email port: ${port}`);
|
||||
}
|
||||
}
|
||||
return normalized.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private normalizePortMapping(portMapping: Record<number, number>): Record<number, number> {
|
||||
const normalized: Record<number, number> = {};
|
||||
for (const [externalPortString, internalPortValue] of Object.entries(portMapping)) {
|
||||
const externalPort = Number(externalPortString);
|
||||
const internalPort = Number(internalPortValue);
|
||||
for (const port of [externalPort, internalPort]) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid email port mapping value: ${port}`);
|
||||
}
|
||||
}
|
||||
normalized[externalPort] = internalPort;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
export * from './classes.email-settings.manager.js';
|
||||
export * from './classes.smartmta-storage-manager.js';
|
||||
export * from './classes.workapp-mail-manager.js';
|
||||
export * from './email-dns-records.js';
|
||||
|
||||
@@ -23,6 +23,7 @@ export class OpsServer {
|
||||
private statsHandler!: handlers.StatsHandler;
|
||||
private radiusHandler!: handlers.RadiusHandler;
|
||||
private emailOpsHandler!: handlers.EmailOpsHandler;
|
||||
private emailSettingsHandler!: handlers.EmailSettingsHandler;
|
||||
private certificateHandler!: handlers.CertificateHandler;
|
||||
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
@@ -82,6 +83,7 @@ export class OpsServer {
|
||||
this.statsHandler = new handlers.StatsHandler(this);
|
||||
this.radiusHandler = new handlers.RadiusHandler(this);
|
||||
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||
this.emailSettingsHandler = new handlers.EmailSettingsHandler(this);
|
||||
this.certificateHandler = new handlers.CertificateHandler(this);
|
||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
|
||||
@@ -61,17 +61,6 @@ export class CertificateHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Legacy route-based reprovision (backward compat)
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||
'reprovisionCertificate',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'certificates:write');
|
||||
return this.reprovisionCertificateByRoute(dataArg.routeName);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Domain-based reprovision (preferred)
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
@@ -336,42 +325,6 @@ export class CertificateHandler {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy route-based reprovisioning. Kept for backward compatibility with
|
||||
* older clients that send `reprovisionCertificate` typed-requests.
|
||||
*
|
||||
* Like reprovisionCertificateDomain, this triggers the full route apply
|
||||
* pipeline rather than smartProxy.provisionCertificate(routeName) — which
|
||||
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
|
||||
*/
|
||||
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const smartProxy = dcRouter.smartProxy;
|
||||
|
||||
if (!smartProxy) {
|
||||
return { success: false, message: 'SmartProxy is not running' };
|
||||
}
|
||||
|
||||
// Clear event-based status for domains in this route so the
|
||||
// certificate-issued event can refresh them
|
||||
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
||||
if (entry.routeNames.includes(routeName)) {
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (dcRouter.routeConfigManager) {
|
||||
await dcRouter.routeConfigManager.applyRoutes();
|
||||
} else {
|
||||
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
|
||||
}
|
||||
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain-based reprovisioning — clears backoff first, refreshes the smartacme
|
||||
* cert (when forceRenew is set), then re-applies routes so the running Rust
|
||||
|
||||
@@ -39,14 +39,7 @@ export class ConfigHandler {
|
||||
? 'custom'
|
||||
: 'filesystem';
|
||||
|
||||
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
||||
let proxyIps = opts.proxyIps || [];
|
||||
if (proxyIps.length === 0 && dcRouter.smartProxy) {
|
||||
const spSettings = (dcRouter.smartProxy as any).settings;
|
||||
if (spSettings?.proxyIPs?.length > 0) {
|
||||
proxyIps = spSettings.proxyIPs;
|
||||
}
|
||||
}
|
||||
const proxyIps = opts.proxyIps || [];
|
||||
|
||||
const system: interfaces.requests.IConfigData['system'] = {
|
||||
baseDir: resolvedPaths.dcrouterHomeDir,
|
||||
@@ -59,15 +52,15 @@ export class ConfigHandler {
|
||||
};
|
||||
|
||||
// --- SmartProxy ---
|
||||
const acmeConfig = dcRouter.acmeConfigManager?.getConfig();
|
||||
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
|
||||
if (opts.smartProxyConfig?.acme) {
|
||||
const acme = opts.smartProxyConfig.acme;
|
||||
if (acmeConfig) {
|
||||
acmeInfo = {
|
||||
enabled: acme.enabled !== false,
|
||||
accountEmail: acme.accountEmail || '',
|
||||
useProduction: acme.useProduction !== false,
|
||||
autoRenew: acme.autoRenew !== false,
|
||||
renewThresholdDays: acme.renewThresholdDays || 30,
|
||||
enabled: acmeConfig.enabled,
|
||||
accountEmail: acmeConfig.accountEmail,
|
||||
useProduction: acmeConfig.useProduction,
|
||||
autoRenew: acmeConfig.autoRenew,
|
||||
renewThresholdDays: acmeConfig.renewThresholdDays,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,21 +93,23 @@ export class ConfigHandler {
|
||||
}
|
||||
|
||||
let portMapping: Record<string, number> | null = null;
|
||||
if (opts.emailPortConfig?.portMapping) {
|
||||
const emailSettings = dcRouter.emailSettingsManager?.getPublicSettings();
|
||||
const rawPortMapping = emailSettings?.portMapping || opts.emailPortConfig?.portMapping;
|
||||
if (rawPortMapping) {
|
||||
portMapping = {};
|
||||
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
|
||||
for (const [ext, int] of Object.entries(rawPortMapping)) {
|
||||
portMapping[String(ext)] = int as number;
|
||||
}
|
||||
}
|
||||
|
||||
const email: interfaces.requests.IConfigData['email'] = {
|
||||
enabled: !!dcRouter.emailServer,
|
||||
ports: opts.emailConfig?.ports || [],
|
||||
enabled: emailSettings?.enabled ?? !!dcRouter.emailServer,
|
||||
ports: emailSettings?.ports || opts.emailConfig?.ports || [],
|
||||
portMapping,
|
||||
hostname: opts.emailConfig?.hostname || null,
|
||||
hostname: emailSettings?.hostname || opts.emailConfig?.hostname || null,
|
||||
domains: emailDomains,
|
||||
emailRouteCount: opts.emailConfig?.routes?.length || 0,
|
||||
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
|
||||
emailRouteCount: emailSettings?.routeCount ?? opts.emailConfig?.routes?.length ?? 0,
|
||||
receivedEmailsPath: emailSettings?.receivedEmailsPath || opts.emailPortConfig?.receivedEmailsPath || null,
|
||||
};
|
||||
|
||||
// --- DNS ---
|
||||
@@ -125,8 +120,7 @@ export class ConfigHandler {
|
||||
ttl: r.ttl,
|
||||
}));
|
||||
|
||||
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB
|
||||
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
|
||||
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB.
|
||||
let dnsChallengeEnabled = false;
|
||||
try {
|
||||
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
|
||||
@@ -148,12 +142,12 @@ export class ConfigHandler {
|
||||
let tlsSource: 'acme' | 'static' | 'none' = 'none';
|
||||
if (opts.tls?.certPath && opts.tls?.keyPath) {
|
||||
tlsSource = 'static';
|
||||
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
|
||||
} else if (acmeConfig?.enabled) {
|
||||
tlsSource = 'acme';
|
||||
}
|
||||
|
||||
const tls: interfaces.requests.IConfigData['tls'] = {
|
||||
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
|
||||
contactEmail: acmeConfig?.accountEmail || null,
|
||||
domain: opts.tls?.domain || null,
|
||||
source: tlsSource,
|
||||
certPath: opts.tls?.certPath || null,
|
||||
@@ -186,16 +180,17 @@ export class ConfigHandler {
|
||||
|
||||
// --- Remote Ingress ---
|
||||
const riCfg = opts.remoteIngressConfig;
|
||||
const riSettings = dcRouter.remoteIngressManager?.getHubSettings();
|
||||
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
|
||||
|
||||
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
|
||||
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
|
||||
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
|
||||
tlsMode = 'custom';
|
||||
} else if (riCfg?.hubDomain) {
|
||||
} else if (riSettings?.hubDomain) {
|
||||
try {
|
||||
const { ProxyCertDoc } = await import('../../db/index.js');
|
||||
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
|
||||
const stored = await ProxyCertDoc.findByDomain(riSettings.hubDomain);
|
||||
if (stored?.publicKey && stored?.privateKey) {
|
||||
tlsMode = 'acme';
|
||||
}
|
||||
@@ -203,12 +198,12 @@ export class ConfigHandler {
|
||||
}
|
||||
|
||||
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
|
||||
enabled: !!dcRouter.remoteIngressManager,
|
||||
tunnelPort: riCfg?.tunnelPort || null,
|
||||
hubDomain: riCfg?.hubDomain || null,
|
||||
enabled: !!riSettings?.enabled,
|
||||
tunnelPort: riSettings?.tunnelPort || null,
|
||||
hubDomain: riSettings?.hubDomain || null,
|
||||
tlsMode,
|
||||
connectedEdgeIps,
|
||||
performance: dcRouter.remoteIngressManager?.getHubPerformanceConfig() || riCfg?.performance,
|
||||
performance: riSettings?.performance,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
export class EmailSettingsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailServerSettings>(
|
||||
'getEmailServerSettings',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'email-domains:read' as any });
|
||||
return { settings: this.getSettings() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailServerSettings>(
|
||||
'updateEmailServerSettings',
|
||||
async (dataArg) => {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'email-domains:write' as any,
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'EmailSettingsManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const settings = await this.opsServerRef.dcRouterRef.updateEmailServerSettings(
|
||||
dataArg.settings,
|
||||
auth.userId,
|
||||
);
|
||||
return { success: true, settings };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private getSettings(): interfaces.data.IEmailServerSettings {
|
||||
const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
|
||||
if (manager) {
|
||||
return manager.getPublicSettings();
|
||||
}
|
||||
const emailConfig = this.opsServerRef.dcRouterRef.options.emailConfig;
|
||||
const emailPortConfig = this.opsServerRef.dcRouterRef.options.emailPortConfig;
|
||||
return {
|
||||
enabled: Boolean(emailConfig),
|
||||
hostname: emailConfig?.hostname || null,
|
||||
ports: [...(emailConfig?.ports || [])],
|
||||
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
|
||||
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
|
||||
maxMessageSize: emailConfig?.maxMessageSize ?? null,
|
||||
domainCount: emailConfig?.domains?.length || 0,
|
||||
routeCount: emailConfig?.routes?.length || 0,
|
||||
authUserCount: emailConfig?.auth?.users?.length || 0,
|
||||
updatedAt: 0,
|
||||
updatedBy: 'runtime-options',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from './security.handler.js';
|
||||
export * from './stats.handler.js';
|
||||
export * from './radius.handler.js';
|
||||
export * from './email-ops.handler.js';
|
||||
export * from './email-settings.handler.js';
|
||||
export * from './certificate.handler.js';
|
||||
export * from './remoteingress.handler.js';
|
||||
export * from './route-management.handler.js';
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
function hasOwn(objectArg: object, keyArg: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
|
||||
}
|
||||
|
||||
export class RemoteIngressHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.registerHandlers();
|
||||
@@ -197,6 +201,8 @@ export class RemoteIngressHandler {
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
return {
|
||||
settings: manager?.getHubSettings() || {
|
||||
enabled: false,
|
||||
tunnelPort: 8443,
|
||||
updatedAt: 0,
|
||||
updatedBy: 'default',
|
||||
},
|
||||
@@ -216,8 +222,22 @@ export class RemoteIngressHandler {
|
||||
});
|
||||
|
||||
try {
|
||||
const updates: interfaces.data.TRemoteIngressHubSettingsUpdate = {};
|
||||
if (hasOwn(dataArg, 'enabled') && dataArg.enabled !== undefined) {
|
||||
updates.enabled = dataArg.enabled;
|
||||
}
|
||||
if (hasOwn(dataArg, 'tunnelPort') && dataArg.tunnelPort !== undefined) {
|
||||
updates.tunnelPort = dataArg.tunnelPort;
|
||||
}
|
||||
if (hasOwn(dataArg, 'hubDomain')) {
|
||||
updates.hubDomain = dataArg.hubDomain ?? null;
|
||||
}
|
||||
if (hasOwn(dataArg, 'performance')) {
|
||||
updates.performance = dataArg.performance ?? null;
|
||||
}
|
||||
|
||||
const settings = await this.opsServerRef.dcRouterRef.updateRemoteIngressHubSettings(
|
||||
{ performance: dataArg.performance },
|
||||
updates,
|
||||
auth.userId,
|
||||
);
|
||||
return { success: true, settings };
|
||||
@@ -250,16 +270,16 @@ export class RemoteIngressHandler {
|
||||
return { success: false, message: 'Edge is disabled' };
|
||||
}
|
||||
|
||||
const hubHost = dataArg.hubHost
|
||||
|| this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.hubDomain;
|
||||
const hubSettings = manager.getHubSettings();
|
||||
const hubHost = dataArg.hubHost || hubSettings.hubDomain;
|
||||
if (!hubHost) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No hub hostname configured. Set hubDomain in remoteIngressConfig or provide hubHost.',
|
||||
message: 'No hub hostname configured. Set the RemoteIngress hub domain or provide hubHost.',
|
||||
};
|
||||
}
|
||||
|
||||
const hubPort = this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.tunnelPort ?? 8443;
|
||||
const hubPort = hubSettings.tunnelPort;
|
||||
|
||||
const token = plugins.remoteingress.encodeConnectionToken({
|
||||
hubHost,
|
||||
|
||||
@@ -282,7 +282,7 @@ export class WorkHosterHandler {
|
||||
outbound: Boolean(dcRouter.emailServer),
|
||||
},
|
||||
remoteIngress: {
|
||||
enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled),
|
||||
enabled: Boolean(dcRouter.remoteIngressManager?.getHubSettings().enabled),
|
||||
},
|
||||
dns: {
|
||||
authoritative: Boolean(dcRouter.options.dnsScopes?.length),
|
||||
@@ -587,7 +587,13 @@ export class WorkHosterHandler {
|
||||
return { success: false, message: 'route is required unless delete=true' };
|
||||
}
|
||||
|
||||
const sourceBindings = this.getManagedRouteSourceBindings();
|
||||
if (!sourceBindings) {
|
||||
return { success: false, message: 'STANDARD source profile not found' };
|
||||
}
|
||||
|
||||
const metadata: interfaces.data.IRouteMetadata = {
|
||||
sourceBindings,
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: resolvedOwnership.gatewayClientType,
|
||||
gatewayClientId: resolvedOwnership.gatewayClientId,
|
||||
@@ -600,8 +606,10 @@ export class WorkHosterHandler {
|
||||
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
|
||||
|
||||
if (existingRoute) {
|
||||
const routePatch: Partial<interfaces.data.IDcRouterRouteConfig> = { ...normalizedRoute };
|
||||
(routePatch as any).security = null;
|
||||
const result = await manager.updateRoute(existingRoute.id, {
|
||||
route: normalizedRoute,
|
||||
route: routePatch,
|
||||
enabled: enabled ?? true,
|
||||
metadata,
|
||||
});
|
||||
@@ -640,10 +648,26 @@ export class WorkHosterHandler {
|
||||
ownership: Required<interfaces.data.IGatewayClientOwnership>,
|
||||
externalKey: string,
|
||||
): interfaces.data.IDcRouterRouteConfig {
|
||||
const normalizedRoute = { ...route };
|
||||
const normalizedRoute = structuredClone(route);
|
||||
delete normalizedRoute.security;
|
||||
if (!normalizedRoute.name) {
|
||||
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
|
||||
}
|
||||
return normalizedRoute;
|
||||
}
|
||||
|
||||
private getManagedRouteSourceBindings(): interfaces.data.IRouteSourceBinding[] | undefined {
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
const standardProfile = resolver?.listProfiles().find((profile: interfaces.data.ISourceProfile) => {
|
||||
return profile.id.trim().toLowerCase() === 'standard'
|
||||
|| profile.name.trim().toLowerCase() === 'standard';
|
||||
});
|
||||
if (!standardProfile) {
|
||||
return undefined;
|
||||
}
|
||||
return [{
|
||||
sourceProfileRef: standardProfile.id,
|
||||
sourceProfileName: standardProfile.name,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { RemoteIngressEdgeDoc, RemoteIngressHubSettingsDoc } from '../db/index.js';
|
||||
|
||||
interface IRemoteIngressFirewallConfig {
|
||||
@@ -30,6 +30,11 @@ const performanceIntegerMaxByField: Record<TPerformanceIntegerField, number> = {
|
||||
};
|
||||
|
||||
const maxServerFirstPorts = 128;
|
||||
const defaultTunnelPort = 8443;
|
||||
|
||||
function hasOwn(objectArg: object, keyArg: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
|
||||
}
|
||||
|
||||
function extractPorts(portRange: plugins.smartproxy.IRouteConfig['match']['ports']): number[] {
|
||||
const ports = new Set<number>(plugins.smartproxy.expandPortRange(portRange) as number[]);
|
||||
@@ -46,12 +51,13 @@ export class RemoteIngressManager {
|
||||
private routes: IDcRouterRouteConfig[] = [];
|
||||
private firewallConfig?: IRemoteIngressFirewallConfig;
|
||||
private hubSettings: IRemoteIngressHubSettings = {
|
||||
enabled: false,
|
||||
tunnelPort: defaultTunnelPort,
|
||||
updatedAt: 0,
|
||||
updatedBy: 'default',
|
||||
};
|
||||
|
||||
constructor(private seedHubPerformance?: IRemoteIngressPerformanceConfig) {
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Load all edge registrations from the database into memory.
|
||||
@@ -86,21 +92,17 @@ export class RemoteIngressManager {
|
||||
private async initializeHubSettings(): Promise<void> {
|
||||
let doc = await RemoteIngressHubSettingsDoc.load();
|
||||
if (!doc) {
|
||||
const seedPerformance = this.normalizePerformanceConfig(this.seedHubPerformance);
|
||||
if (seedPerformance) {
|
||||
doc = new RemoteIngressHubSettingsDoc();
|
||||
doc.settingsId = 'remote-ingress-hub-settings';
|
||||
doc.performance = seedPerformance;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'seed';
|
||||
await doc.save();
|
||||
}
|
||||
doc = new RemoteIngressHubSettingsDoc();
|
||||
doc.settingsId = 'remote-ingress-hub-settings';
|
||||
doc.enabled = false;
|
||||
doc.tunnelPort = defaultTunnelPort;
|
||||
doc.hubDomain = '';
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'default';
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
this.hubSettings = doc ? this.toHubSettings(doc) : {
|
||||
updatedAt: 0,
|
||||
updatedBy: 'default',
|
||||
};
|
||||
this.hubSettings = this.toHubSettings(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,16 +133,30 @@ export class RemoteIngressManager {
|
||||
}
|
||||
|
||||
public async updateHubSettings(
|
||||
updates: { performance?: IRemoteIngressPerformanceConfig },
|
||||
updates: TRemoteIngressHubSettingsUpdate,
|
||||
updatedBy: string,
|
||||
): Promise<IRemoteIngressHubSettings> {
|
||||
let doc = await RemoteIngressHubSettingsDoc.load();
|
||||
if (!doc) {
|
||||
doc = new RemoteIngressHubSettingsDoc();
|
||||
doc.settingsId = 'remote-ingress-hub-settings';
|
||||
doc.enabled = false;
|
||||
doc.tunnelPort = defaultTunnelPort;
|
||||
}
|
||||
|
||||
doc.performance = this.normalizePerformanceConfig(updates.performance);
|
||||
const normalized = this.normalizeHubSettingsUpdate(updates);
|
||||
if (hasOwn(normalized, 'enabled')) {
|
||||
doc.enabled = normalized.enabled;
|
||||
}
|
||||
if (hasOwn(normalized, 'tunnelPort')) {
|
||||
doc.tunnelPort = normalized.tunnelPort;
|
||||
}
|
||||
if (hasOwn(updates, 'hubDomain')) {
|
||||
doc.hubDomain = normalized.hubDomain || '';
|
||||
}
|
||||
if (hasOwn(updates, 'performance')) {
|
||||
doc.performance = normalized.performance || undefined;
|
||||
}
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = updatedBy;
|
||||
await doc.save();
|
||||
@@ -408,6 +424,34 @@ export class RemoteIngressManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
private normalizeHubSettingsUpdate(
|
||||
updates: TRemoteIngressHubSettingsUpdate,
|
||||
): TRemoteIngressHubSettingsUpdate {
|
||||
const next: TRemoteIngressHubSettingsUpdate = {};
|
||||
|
||||
if (hasOwn(updates, 'enabled') && updates.enabled !== undefined) {
|
||||
next.enabled = Boolean(updates.enabled);
|
||||
}
|
||||
if (hasOwn(updates, 'tunnelPort') && updates.tunnelPort !== undefined) {
|
||||
const tunnelPort = Number(updates.tunnelPort);
|
||||
if (!Number.isInteger(tunnelPort) || tunnelPort < 1 || tunnelPort > 65535) {
|
||||
throw new Error('tunnelPort must be a valid TCP port');
|
||||
}
|
||||
next.tunnelPort = tunnelPort;
|
||||
}
|
||||
if (hasOwn(updates, 'hubDomain')) {
|
||||
const hubDomain = `${updates.hubDomain || ''}`.trim();
|
||||
next.hubDomain = hubDomain || undefined;
|
||||
}
|
||||
if (hasOwn(updates, 'performance')) {
|
||||
next.performance = updates.performance === null
|
||||
? undefined
|
||||
: this.normalizePerformanceConfig(updates.performance || undefined);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private normalizePerformanceConfig(
|
||||
performance?: IRemoteIngressPerformanceConfig,
|
||||
): IRemoteIngressPerformanceConfig | undefined {
|
||||
@@ -488,6 +532,9 @@ export class RemoteIngressManager {
|
||||
|
||||
private toHubSettings(doc: RemoteIngressHubSettingsDoc): IRemoteIngressHubSettings {
|
||||
return {
|
||||
enabled: doc.enabled ?? false,
|
||||
tunnelPort: doc.tunnelPort ?? defaultTunnelPort,
|
||||
hubDomain: doc.hubDomain || undefined,
|
||||
performance: doc.performance,
|
||||
updatedAt: doc.updatedAt,
|
||||
updatedBy: doc.updatedBy,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
|
||||
|
||||
export interface ITunnelManagerConfig {
|
||||
@@ -9,7 +9,7 @@ export interface ITunnelManagerConfig {
|
||||
certPem?: string;
|
||||
keyPem?: string;
|
||||
};
|
||||
performance?: import('../../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,18 +46,20 @@ export class TunnelManager {
|
||||
this.edgeStatuses.delete(data.edgeId);
|
||||
});
|
||||
|
||||
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
|
||||
this.hub.on('streamSummary', (data: {
|
||||
edgeId: string;
|
||||
activeStreams: number;
|
||||
streamsOpenedTotal: number;
|
||||
streamsClosedTotal: number;
|
||||
}) => {
|
||||
const existing = this.edgeStatuses.get(data.edgeId);
|
||||
if (existing) {
|
||||
existing.activeTunnels++;
|
||||
existing.activeTunnels = data.activeStreams;
|
||||
existing.lastHeartbeat = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => {
|
||||
const existing = this.edgeStatuses.get(data.edgeId);
|
||||
if (existing && existing.activeTunnels > 0) {
|
||||
existing.activeTunnels--;
|
||||
if (existing.traffic) {
|
||||
existing.traffic.streamsOpenedTotal = data.streamsOpenedTotal;
|
||||
existing.traffic.streamsClosedTotal = data.streamsClosedTotal;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -73,6 +75,7 @@ export class TunnelManager {
|
||||
targetHost: this.config.targetHost ?? '127.0.0.1',
|
||||
tls: this.config.tls,
|
||||
...(this.config.performance ? { performance: this.config.performance } : {}),
|
||||
streamEventMode: 'summary',
|
||||
} as any);
|
||||
|
||||
if (this.stopped) return;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* ACME configuration for automated TLS certificate issuance via Let's Encrypt.
|
||||
*
|
||||
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb. Replaces the
|
||||
* legacy constructor fields `tls.contactEmail` / `smartProxyConfig.acme.*`
|
||||
* which are now seed-only (used once on first boot if the DB is empty).
|
||||
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb.
|
||||
*
|
||||
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
export interface IEmailPortConfig {
|
||||
/** External to internal SMTP port mapping. */
|
||||
portMapping?: Record<number, number>;
|
||||
/** Custom route settings for specific external ports. */
|
||||
portSettings?: Record<number, {
|
||||
terminateTls?: boolean;
|
||||
routeName?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
/** Path to store received emails, when configured by the runtime. */
|
||||
receivedEmailsPath?: string;
|
||||
}
|
||||
|
||||
export interface IEmailServerSettings {
|
||||
enabled: boolean;
|
||||
hostname: string | null;
|
||||
ports: number[];
|
||||
portMapping: Record<number, number> | null;
|
||||
receivedEmailsPath: string | null;
|
||||
maxMessageSize: number | null;
|
||||
domainCount: number;
|
||||
routeCount: number;
|
||||
authUserCount: number;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface IEmailServerSettingsSeed {
|
||||
enabled?: boolean;
|
||||
emailConfig?: IUnifiedEmailServerOptions;
|
||||
emailPortConfig?: IEmailPortConfig;
|
||||
}
|
||||
|
||||
export type TEmailServerSettingsUpdate = {
|
||||
enabled?: boolean;
|
||||
hostname?: string | null;
|
||||
ports?: number[];
|
||||
portMapping?: Record<number, number> | null;
|
||||
receivedEmailsPath?: string | null;
|
||||
maxMessageSize?: number | null;
|
||||
};
|
||||
@@ -10,4 +10,5 @@ export * from './workhoster.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
export * from './email-settings.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -64,11 +64,19 @@ export interface IRemoteIngressPerformanceConfig {
|
||||
}
|
||||
|
||||
export interface IRemoteIngressHubSettings {
|
||||
enabled: boolean;
|
||||
tunnelPort: number;
|
||||
hubDomain?: string;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export type TRemoteIngressHubSettingsUpdate = Partial<Pick<IRemoteIngressHubSettings, 'enabled' | 'tunnelPort'>> & {
|
||||
hubDomain?: string | null;
|
||||
performance?: IRemoteIngressPerformanceConfig | null;
|
||||
};
|
||||
|
||||
export interface IRemoteIngressPerformanceEffective {
|
||||
profile: TRemoteIngressPerformanceProfile;
|
||||
maxStreamsPerEdge: number;
|
||||
|
||||
@@ -190,7 +190,7 @@ export interface IRoutePathPolicyBinding {
|
||||
onExceeded?: IRouteSourcePolicyExceededAction;
|
||||
}
|
||||
|
||||
export interface IRouteSourcePolicyBinding {
|
||||
export interface IRouteSourceBinding {
|
||||
id?: string;
|
||||
sourceProfileRef: string;
|
||||
/** Snapshot of the profile name at resolution time, for display. */
|
||||
@@ -205,9 +205,13 @@ export interface IRouteSourcePolicyBinding {
|
||||
pathPolicies?: IRoutePathPolicyBinding[];
|
||||
}
|
||||
|
||||
/** @deprecated Use IRouteSourceBinding and IRouteMetadata.sourceBindings. */
|
||||
export type IRouteSourcePolicyBinding = IRouteSourceBinding;
|
||||
|
||||
/** @deprecated Use IRouteMetadata.sourceBindings. */
|
||||
export interface IRouteSourcePolicy {
|
||||
/** Ordered source profile bindings. The first matching binding wins. */
|
||||
bindings: IRouteSourcePolicyBinding[];
|
||||
bindings: IRouteSourceBinding[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -236,14 +240,10 @@ export interface INetworkTarget {
|
||||
* Metadata on a stored route tracking where its resolved values came from.
|
||||
*/
|
||||
export interface IRouteMetadata {
|
||||
/** ID of the SourceProfileDoc used to resolve this route's security. */
|
||||
sourceProfileRef?: string;
|
||||
/** Ordered source policy. When present, it supersedes sourceProfileRef. */
|
||||
sourcePolicy?: IRouteSourcePolicy;
|
||||
/** Ordered source profile bindings. The first matching source profile wins. */
|
||||
sourceBindings?: IRouteSourceBinding[];
|
||||
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
|
||||
networkTargetRef?: string;
|
||||
/** Snapshot of the profile name at resolution time, for display. */
|
||||
sourceProfileName?: string;
|
||||
/** Snapshot of the target name at resolution time, for display. */
|
||||
networkTargetName?: string;
|
||||
/** Timestamp of last reference resolution. */
|
||||
|
||||
@@ -22,7 +22,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `data` | Shared runtime-shaped models such as identities, routes, route source policies, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
|
||||
| `data` | Shared runtime-shaped models such as identities, routes, route source bindings, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
|
||||
| `requests` | TypedRequest request/response contracts for OpsServer methods. |
|
||||
| `typedrequestInterfaces` | Helper types re-exported from `@api.global/typedrequest-interfaces` through `plugins.ts`. |
|
||||
|
||||
@@ -31,7 +31,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
| Area | Examples |
|
||||
| --- | --- |
|
||||
| Auth | admin login, first-admin bootstrap status/creation, logout, identity verification, users |
|
||||
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata, ordered source/path policies |
|
||||
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata, ordered source/path bindings |
|
||||
| Access | API tokens, source profiles, target profiles, network targets |
|
||||
| DNS and domains | DNS providers, domains, DNS records, ACME config |
|
||||
| Email | email-domain management and email operations |
|
||||
@@ -39,14 +39,15 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
| Observability | stats, combined stats, logs, configuration |
|
||||
| WorkHoster | external app/workhoster route ownership contracts |
|
||||
|
||||
## Route Source Policy Contracts
|
||||
## Route Source Binding Contracts
|
||||
|
||||
`data/route-management.ts` exports the source-policy contracts used by the dashboard, API client, and route runtime compiler:
|
||||
`data/route-management.ts` exports the source-binding contracts used by the dashboard, API client, and route runtime compiler:
|
||||
|
||||
- `IRouteSourcePolicy` stores ordered route-level source bindings.
|
||||
- `IRouteSourcePolicyBinding` points to a source profile, can override rate limits or connection limits, and can contain path policies.
|
||||
- `IRouteMetadata.sourceBindings` stores ordered route-level source bindings.
|
||||
- `IRouteSourceBinding` points to a source profile, can override rate limits or connection limits, and can contain path policies.
|
||||
- `IRoutePathPolicyBinding` applies path-class-specific overrides within a source binding.
|
||||
- `IRouteSourcePolicyExceededAction` describes terminal exceeded-limit behavior, currently explicit `429` handling.
|
||||
- `IRouteSourcePolicy` and `IRouteSourcePolicyBinding` remain deprecated type aliases for old integrations; active route metadata uses `sourceBindings[]`.
|
||||
- `TRoutePathClass` is the string-union type derived from `routePathClasses`.
|
||||
- `routePathClasses` lists the supported classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`.
|
||||
- `giteaRoutePathClassLabels` and `giteaRoutePathClassPatterns` provide the built-in Gitea labels and path patterns, including Git Smart HTTP and Git LFS patterns.
|
||||
|
||||
@@ -44,24 +44,6 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy route-based reprovision (kept for backward compat)
|
||||
export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ReprovisionCertificate
|
||||
> {
|
||||
method: 'reprovisionCertificate';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Domain-based reprovision (preferred)
|
||||
export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ReprovisionCertificateDomain
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IEmailServerSettings, TEmailServerSettingsUpdate } from '../data/email-settings.js';
|
||||
|
||||
export interface IReq_GetEmailServerSettings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailServerSettings
|
||||
> {
|
||||
method: 'getEmailServerSettings';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
settings: IEmailServerSettings;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateEmailServerSettings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateEmailServerSettings
|
||||
> {
|
||||
method: 'updateEmailServerSettings';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
settings: TEmailServerSettingsUpdate;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
settings?: IEmailServerSettings;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -19,5 +19,6 @@ export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domains.js';
|
||||
export * from './email-settings.js';
|
||||
export * from './workhoster.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -176,7 +176,10 @@ export interface IReq_UpdateRemoteIngressHubSettings extends plugins.typedreques
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
enabled?: boolean;
|
||||
tunnelPort?: number;
|
||||
hubDomain?: string | null;
|
||||
performance?: IRemoteIngressPerformanceConfig | null;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
|
||||
+263
-2
@@ -21,6 +21,20 @@ export interface IMigrationRunner {
|
||||
|
||||
type TMigrationSecurity = Record<string, any>;
|
||||
|
||||
export interface IDcRouterMigrationOptions {
|
||||
remoteIngressHubSettings?: {
|
||||
enabled?: boolean;
|
||||
tunnelPort?: number;
|
||||
hubDomain?: string | null;
|
||||
performance?: Record<string, any> | null;
|
||||
};
|
||||
emailServerSettings?: {
|
||||
enabled?: boolean;
|
||||
emailConfig?: Record<string, any>;
|
||||
emailPortConfig?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SOURCE_PROFILES: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -62,6 +76,19 @@ const DEFAULT_SOURCE_PROFILES: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
const remoteIngressHubSettingsMigrationBaseVersion = '13.43.5';
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const aParts = a.split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
const bParts = b.split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
const maxLength = Math.max(aParts.length, bParts.length);
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const diff = (aParts[i] || 0) - (bParts[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mergeMigrationSecurityFields(
|
||||
base: TMigrationSecurity | undefined,
|
||||
override: TMigrationSecurity | undefined,
|
||||
@@ -270,6 +297,215 @@ async function seedMissingDefaultSourceProfiles(ctx: {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMigrationSourceBinding(binding: any, profiles: Map<string, any>): any | undefined {
|
||||
if (!binding || typeof binding !== 'object') return undefined;
|
||||
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
|
||||
? binding.sourceProfileRef.trim()
|
||||
: '';
|
||||
if (!sourceProfileRef) return undefined;
|
||||
|
||||
const profile = profiles.get(sourceProfileRef);
|
||||
const normalizedBinding = structuredClone(binding);
|
||||
normalizedBinding.sourceProfileRef = sourceProfileRef;
|
||||
const sourceProfileName = typeof normalizedBinding.sourceProfileName === 'string'
|
||||
? normalizedBinding.sourceProfileName.trim()
|
||||
: '';
|
||||
if (sourceProfileName) {
|
||||
normalizedBinding.sourceProfileName = sourceProfileName;
|
||||
} else if (typeof profile?.name === 'string' && profile.name.trim()) {
|
||||
normalizedBinding.sourceProfileName = profile.name.trim();
|
||||
} else {
|
||||
delete normalizedBinding.sourceProfileName;
|
||||
}
|
||||
|
||||
return normalizedBinding;
|
||||
}
|
||||
|
||||
async function convertRouteAccessMetadataToSourceBindings(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const profileCollection = ctx.mongo!.collection('SourceProfileDoc');
|
||||
const routeCollection = ctx.mongo!.collection('RouteDoc');
|
||||
const profiles = new Map<string, any>();
|
||||
const now = Date.now();
|
||||
|
||||
for await (const profile of profileCollection.find({})) {
|
||||
if (typeof (profile as any).id === 'string') {
|
||||
profiles.set((profile as any).id, profile);
|
||||
}
|
||||
}
|
||||
|
||||
let inspected = 0;
|
||||
let migrated = 0;
|
||||
for await (const routeDoc of routeCollection.find({})) {
|
||||
const metadata = (routeDoc as any).metadata || {};
|
||||
const existingSourceBindings = Array.isArray(metadata.sourceBindings)
|
||||
? metadata.sourceBindings
|
||||
: [];
|
||||
const legacyPolicyBindings = Array.isArray(metadata.sourcePolicy?.bindings)
|
||||
? metadata.sourcePolicy.bindings
|
||||
: [];
|
||||
const legacySourceProfileRef = typeof metadata.sourceProfileRef === 'string'
|
||||
? metadata.sourceProfileRef.trim()
|
||||
: '';
|
||||
const hasLegacyAccessFields = legacyPolicyBindings.length > 0
|
||||
|| legacySourceProfileRef.length > 0
|
||||
|| metadata.sourcePolicy !== undefined
|
||||
|| metadata.sourceProfileRef !== undefined
|
||||
|| metadata.sourceProfileName !== undefined;
|
||||
|
||||
if (!hasLegacyAccessFields && existingSourceBindings.length === 0) {
|
||||
continue;
|
||||
}
|
||||
inspected++;
|
||||
|
||||
const sourceBindings = existingSourceBindings.length > 0
|
||||
? existingSourceBindings
|
||||
.map((binding: any) => normalizeMigrationSourceBinding(binding, profiles))
|
||||
.filter(Boolean)
|
||||
: legacyPolicyBindings.length > 0
|
||||
? legacyPolicyBindings
|
||||
.map((binding: any) => normalizeMigrationSourceBinding(binding, profiles))
|
||||
.filter(Boolean)
|
||||
: legacySourceProfileRef
|
||||
? [normalizeMigrationSourceBinding({
|
||||
sourceProfileRef: legacySourceProfileRef,
|
||||
sourceProfileName: metadata.sourceProfileName,
|
||||
}, profiles)].filter(Boolean)
|
||||
: [];
|
||||
|
||||
const $set: Record<string, any> = { updatedAt: now };
|
||||
const $unset: Record<string, ''> = {
|
||||
'metadata.sourcePolicy': '',
|
||||
'metadata.sourceProfileRef': '',
|
||||
'metadata.sourceProfileName': '',
|
||||
};
|
||||
|
||||
if (sourceBindings.length > 0) {
|
||||
$set['metadata.sourceBindings'] = sourceBindings;
|
||||
$set['metadata.lastResolvedAt'] = now;
|
||||
} else if (existingSourceBindings.length === 0) {
|
||||
$unset['metadata.sourceBindings'] = '';
|
||||
}
|
||||
|
||||
if (existingSourceBindings.length === 0 && legacyPolicyBindings.length === 0 && legacySourceProfileRef) {
|
||||
$unset['route.security'] = '';
|
||||
}
|
||||
|
||||
const query = (routeDoc as any)._id
|
||||
? { _id: (routeDoc as any)._id }
|
||||
: { id: (routeDoc as any).id };
|
||||
await routeCollection.updateOne(query, { $set, $unset });
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`convert-route-access-metadata-to-source-bindings: migrated ${migrated}/${inspected} route(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
async function backfillRemoteIngressHubSettings(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}, options: IDcRouterMigrationOptions): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('RemoteIngressHubSettingsDoc');
|
||||
const seed = options.remoteIngressHubSettings || {};
|
||||
const now = Date.now();
|
||||
const doc = await collection.findOne({ settingsId: 'remote-ingress-hub-settings' });
|
||||
|
||||
if (!doc) {
|
||||
await collection.insertOne({
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
enabled: seed.enabled ?? false,
|
||||
tunnelPort: seed.tunnelPort ?? 8443,
|
||||
hubDomain: seed.hubDomain || '',
|
||||
performance: seed.performance || undefined,
|
||||
updatedAt: now,
|
||||
updatedBy: 'migration',
|
||||
});
|
||||
ctx.log.log('info', 'backfill-remote-ingress-hub-settings: inserted singleton settings document');
|
||||
return;
|
||||
}
|
||||
|
||||
const $set: Record<string, any> = {};
|
||||
if ((doc as any).enabled === undefined) {
|
||||
$set.enabled = seed.enabled ?? false;
|
||||
}
|
||||
if ((doc as any).tunnelPort === undefined) {
|
||||
$set.tunnelPort = seed.tunnelPort ?? 8443;
|
||||
}
|
||||
if ((doc as any).hubDomain === undefined && seed.hubDomain) {
|
||||
$set.hubDomain = seed.hubDomain;
|
||||
}
|
||||
if ((doc as any).performance === undefined && seed.performance) {
|
||||
$set.performance = seed.performance;
|
||||
}
|
||||
|
||||
if (Object.keys($set).length === 0) {
|
||||
ctx.log.log('info', 'backfill-remote-ingress-hub-settings: no changes needed');
|
||||
return;
|
||||
}
|
||||
|
||||
$set.updatedAt = now;
|
||||
$set.updatedBy = (doc as any).updatedBy || 'migration';
|
||||
|
||||
await collection.updateOne(
|
||||
(doc as any)._id ? { _id: (doc as any)._id } : { settingsId: 'remote-ingress-hub-settings' },
|
||||
{ $set },
|
||||
);
|
||||
ctx.log.log('info', `backfill-remote-ingress-hub-settings: set ${Object.keys($set).length - 2} missing field(s)`);
|
||||
}
|
||||
|
||||
async function backfillEmailServerSettings(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}, options: IDcRouterMigrationOptions): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('EmailServerSettingsDoc');
|
||||
const seed = options.emailServerSettings || {};
|
||||
const now = Date.now();
|
||||
const doc = await collection.findOne({ settingsId: 'email-server-settings' });
|
||||
|
||||
if (!doc) {
|
||||
await collection.insertOne({
|
||||
settingsId: 'email-server-settings',
|
||||
enabled: seed.enabled ?? Boolean(seed.emailConfig),
|
||||
emailConfig: seed.emailConfig || undefined,
|
||||
emailPortConfig: seed.emailPortConfig || undefined,
|
||||
updatedAt: now,
|
||||
updatedBy: 'migration',
|
||||
});
|
||||
ctx.log.log('info', 'backfill-email-server-settings: inserted singleton settings document');
|
||||
return;
|
||||
}
|
||||
|
||||
const $set: Record<string, any> = {};
|
||||
if ((doc as any).enabled === undefined) {
|
||||
$set.enabled = seed.enabled ?? Boolean(seed.emailConfig);
|
||||
}
|
||||
if ((doc as any).emailConfig === undefined && seed.emailConfig) {
|
||||
$set.emailConfig = seed.emailConfig;
|
||||
}
|
||||
if ((doc as any).emailPortConfig === undefined && seed.emailPortConfig) {
|
||||
$set.emailPortConfig = seed.emailPortConfig;
|
||||
}
|
||||
|
||||
if (Object.keys($set).length === 0) {
|
||||
ctx.log.log('info', 'backfill-email-server-settings: no changes needed');
|
||||
return;
|
||||
}
|
||||
|
||||
$set.updatedAt = now;
|
||||
$set.updatedBy = (doc as any).updatedBy || 'migration';
|
||||
|
||||
await collection.updateOne(
|
||||
(doc as any)._id ? { _id: (doc as any)._id } : { settingsId: 'email-server-settings' },
|
||||
{ $set },
|
||||
);
|
||||
ctx.log.log('info', `backfill-email-server-settings: set ${Object.keys($set).length - 2} missing field(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -282,6 +518,7 @@ async function seedMissingDefaultSourceProfiles(ctx: {
|
||||
export async function createMigrationRunner(
|
||||
db: unknown,
|
||||
targetVersion: string,
|
||||
options: IDcRouterMigrationOptions = {},
|
||||
): Promise<IMigrationRunner> {
|
||||
const sm = await import('@push.rocks/smartmigration');
|
||||
const migration = new sm.SmartMigration({
|
||||
@@ -304,7 +541,7 @@ export async function createMigrationRunner(
|
||||
.from('13.1.0').to('13.8.1')
|
||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('domaindoc');
|
||||
const collection = ctx.mongo!.collection('DomainDoc');
|
||||
const result = await collection.updateMany(
|
||||
{ source: 'manual' },
|
||||
{ $set: { source: 'dcrouter' } },
|
||||
@@ -318,7 +555,7 @@ export async function createMigrationRunner(
|
||||
.from('13.8.1').to('13.8.2')
|
||||
.description('Rename DnsRecordDoc.source value from "manual" to "local"')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('dnsrecorddoc');
|
||||
const collection = ctx.mongo!.collection('DnsRecordDoc');
|
||||
const result = await collection.updateMany(
|
||||
{ source: 'manual' },
|
||||
{ $set: { source: 'local' } },
|
||||
@@ -379,7 +616,31 @@ export async function createMigrationRunner(
|
||||
.description('Seed missing default source profiles for source-policy presets')
|
||||
.up(async (ctx) => {
|
||||
await seedMissingDefaultSourceProfiles(ctx);
|
||||
})
|
||||
.step('convert-route-access-metadata-to-source-bindings')
|
||||
.from('13.42.0').to('13.43.2')
|
||||
.description('Convert route sourceProfileRef/sourcePolicy metadata to canonical sourceBindings')
|
||||
.up(async (ctx) => {
|
||||
await convertRouteAccessMetadataToSourceBindings(ctx);
|
||||
})
|
||||
.step('backfill-remote-ingress-hub-settings-current')
|
||||
.from('13.43.2').to(remoteIngressHubSettingsMigrationBaseVersion)
|
||||
.description('Backfill RemoteIngress hub singleton settings for current dcrouter 13.43.5 installs')
|
||||
.up(async (ctx) => {
|
||||
await backfillRemoteIngressHubSettings(ctx, options);
|
||||
await backfillEmailServerSettings(ctx, options);
|
||||
});
|
||||
|
||||
if (compareSemver(targetVersion, remoteIngressHubSettingsMigrationBaseVersion) > 0) {
|
||||
migration
|
||||
.step('backfill-remote-ingress-hub-settings')
|
||||
.from(remoteIngressHubSettingsMigrationBaseVersion).to(targetVersion)
|
||||
.description('Backfill DB-backed singleton runtime settings from legacy bootstrap config')
|
||||
.up(async (ctx) => {
|
||||
await backfillRemoteIngressHubSettings(ctx, options);
|
||||
await backfillEmailServerSettings(ctx, options);
|
||||
});
|
||||
}
|
||||
|
||||
return migration;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ The current migration chain covers:
|
||||
- `systemKey` backfill for persisted config, email, and DNS routes
|
||||
- source-profile route-security rematerialization for routes with legacy `metadata.sourceProfileRef`
|
||||
- `seed-missing-default-source-profiles` from `13.40.2` to `13.42.0`, which inserts missing `TRUSTED NETWORKS`, `AI CRAWLERS`, and `PUBLIC` source profiles by name without mutating existing profiles
|
||||
- `convert-route-access-metadata-to-source-bindings` from `13.42.0` to `13.43.2`, which converts legacy `metadata.sourceProfileRef`, `metadata.sourceProfileName`, and `metadata.sourcePolicy.bindings` to canonical `metadata.sourceBindings[]` and removes legacy access metadata fields
|
||||
|
||||
## Migration Rules
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.43.1',
|
||||
version: '14.0.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+33
-1
@@ -1230,7 +1230,10 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
});
|
||||
|
||||
export const updateRemoteIngressHubSettingsAction = remoteIngressStatePart.createAction<{
|
||||
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
|
||||
enabled?: boolean;
|
||||
tunnelPort?: number;
|
||||
hubDomain?: string | null;
|
||||
performance?: interfaces.data.IRemoteIngressPerformanceConfig | null;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
@@ -1242,6 +1245,9 @@ export const updateRemoteIngressHubSettingsAction = remoteIngressStatePart.creat
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
enabled: dataArg.enabled,
|
||||
tunnelPort: dataArg.tunnelPort,
|
||||
hubDomain: dataArg.hubDomain,
|
||||
performance: dataArg.performance,
|
||||
});
|
||||
|
||||
@@ -2956,6 +2962,7 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
||||
|
||||
export interface IEmailDomainsState {
|
||||
domains: interfaces.data.IEmailDomain[];
|
||||
settings: interfaces.data.IEmailServerSettings | null;
|
||||
isLoading: boolean;
|
||||
lastUpdated: number;
|
||||
}
|
||||
@@ -2964,6 +2971,7 @@ export const emailDomainsStatePart = await appState.getStatePart<IEmailDomainsSt
|
||||
'emailDomains',
|
||||
{
|
||||
domains: [],
|
||||
settings: null,
|
||||
isLoading: false,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
@@ -2980,10 +2988,15 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailDomains
|
||||
>('/typedrequest', 'getEmailDomains');
|
||||
const settingsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailServerSettings
|
||||
>('/typedrequest', 'getEmailServerSettings');
|
||||
const response = await request.fire({ identity: context.identity });
|
||||
const settingsResponse = await settingsRequest.fire({ identity: context.identity });
|
||||
return {
|
||||
...currentState,
|
||||
domains: response.domains,
|
||||
settings: settingsResponse.settings,
|
||||
isLoading: false,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
@@ -3014,6 +3027,25 @@ export const createEmailDomainAction = emailDomainsStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
export const updateEmailServerSettingsAction = emailDomainsStatePart.createAction<
|
||||
interfaces.data.TEmailServerSettingsUpdate
|
||||
>(async (statePartArg, settings, actionContext) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateEmailServerSettings
|
||||
>('/typedrequest', 'updateEmailServerSettings');
|
||||
const response = await request.fire({ identity: context.identity!, settings });
|
||||
if (!response.success) {
|
||||
return currentState;
|
||||
}
|
||||
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||
} catch {
|
||||
return currentState;
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteEmailDomainAction = emailDomainsStatePart.createAction<string>(
|
||||
async (statePartArg, id, actionContext) => {
|
||||
const context = getActionContext();
|
||||
|
||||
@@ -101,6 +101,7 @@ export class OpsViewEmailDomains extends DeesElement {
|
||||
|
||||
public render(): TemplateResult {
|
||||
const domains = this.emailDomainsState.domains;
|
||||
const settings = this.emailDomainsState.settings;
|
||||
const validCount = domains.filter(
|
||||
(d) =>
|
||||
d.dnsStatus.mx === 'valid' &&
|
||||
@@ -127,6 +128,22 @@ export class OpsViewEmailDomains extends DeesElement {
|
||||
icon: 'lucide:Check',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
id: 'server',
|
||||
title: 'Server',
|
||||
value: settings?.enabled ? 'enabled' : 'disabled',
|
||||
type: 'text',
|
||||
icon: 'lucide:mail-check',
|
||||
color: settings?.enabled ? '#22c55e' : '#6b7280',
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
title: 'SMTP Ports',
|
||||
value: settings?.ports?.join(', ') || 'none',
|
||||
type: 'text',
|
||||
icon: 'lucide:plug',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
{
|
||||
id: 'issues',
|
||||
title: 'Issues',
|
||||
@@ -163,6 +180,13 @@ export class OpsViewEmailDomains extends DeesElement {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
action: async () => {
|
||||
await this.showSettingsDialog();
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
|
||||
@@ -258,6 +282,108 @@ export class OpsViewEmailDomains extends DeesElement {
|
||||
return html`<span class="sourceBadge">${label}</span>`;
|
||||
}
|
||||
|
||||
private parsePortList(value: string): number[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((part) => Number.parseInt(part.trim(), 10))
|
||||
.filter((port) => Number.isInteger(port));
|
||||
}
|
||||
|
||||
private parsePortMapping(value: string): Record<number, number> | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const mapping: Record<number, number> = {};
|
||||
for (const pair of trimmed.split(',')) {
|
||||
const [externalPort, internalPort] = pair
|
||||
.split(':')
|
||||
.map((part) => Number.parseInt(part.trim(), 10));
|
||||
if (Number.isInteger(externalPort) && Number.isInteger(internalPort)) {
|
||||
mapping[externalPort] = internalPort;
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length > 0 ? mapping : null;
|
||||
}
|
||||
|
||||
private formatPortMapping(mapping: Record<number, number> | null | undefined): string {
|
||||
if (!mapping) return '';
|
||||
return Object.entries(mapping)
|
||||
.map(([externalPort, internalPort]) => `${externalPort}:${internalPort}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
private async showSettingsDialog() {
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
const settings = this.emailDomainsState.settings;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Email Server Settings',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-checkbox
|
||||
.key=${'enabled'}
|
||||
.label=${'Enable email server'}
|
||||
.value=${settings?.enabled ?? false}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-text
|
||||
.key=${'hostname'}
|
||||
.label=${'SMTP hostname'}
|
||||
.description=${'Public hostname used in SMTP banners and DNS records'}
|
||||
.value=${settings?.hostname || ''}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'ports'}
|
||||
.label=${'Public ports'}
|
||||
.description=${'Comma-separated SMTP ingress ports, e.g. 25, 587, 465'}
|
||||
.value=${settings?.ports?.join(', ') || '25, 587, 465'}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'portMapping'}
|
||||
.label=${'Port mapping'}
|
||||
.description=${'Optional external:internal pairs, e.g. 25:10025, 587:10587'}
|
||||
.value=${this.formatPortMapping(settings?.portMapping)}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'maxMessageSize'}
|
||||
.label=${'Max message size'}
|
||||
.description=${'Bytes; leave empty for smartmta default'}
|
||||
.value=${settings?.maxMessageSize ? String(settings.maxMessageSize) : ''}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'receivedEmailsPath'}
|
||||
.label=${'Received emails path'}
|
||||
.description=${'Optional storage path for received email artifacts'}
|
||||
.value=${settings?.receivedEmailsPath || ''}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (m: any) => m.destroy() },
|
||||
{
|
||||
name: 'Save',
|
||||
action: async (m: any) => {
|
||||
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const maxMessageSizeRaw = String(data.maxMessageSize || '').trim();
|
||||
await appstate.emailDomainsStatePart.dispatchAction(
|
||||
appstate.updateEmailServerSettingsAction,
|
||||
{
|
||||
enabled: Boolean(data.enabled),
|
||||
hostname: String(data.hostname || '').trim() || null,
|
||||
ports: this.parsePortList(String(data.ports || '')),
|
||||
portMapping: this.parsePortMapping(String(data.portMapping || '')),
|
||||
maxMessageSize: maxMessageSizeRaw ? Number.parseInt(maxMessageSizeRaw, 10) : null,
|
||||
receivedEmailsPath: String(data.receivedEmailsPath || '').trim() || null,
|
||||
},
|
||||
);
|
||||
DeesToast.show({ message: 'Email settings saved', type: 'success', duration: 2500 });
|
||||
m.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showCreateDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const domainOptions = this.domainsState.domains.map((d) => ({
|
||||
|
||||
@@ -620,16 +620,34 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
|
||||
private async showHubSettingsDialog(): Promise<void> {
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
const performance = this.riState.hubSettings?.performance || {};
|
||||
const hubSettings = this.riState.hubSettings;
|
||||
const performance = hubSettings?.performance || {};
|
||||
const selectedProfile = performanceProfileOptions.find((option) => option.key === (performance.profile || '')) || performanceProfileOptions[0];
|
||||
const updatedAt = this.riState.hubSettings?.updatedAt
|
||||
? new Date(this.riState.hubSettings.updatedAt).toLocaleString()
|
||||
const updatedAt = hubSettings?.updatedAt
|
||||
? new Date(hubSettings.updatedAt).toLocaleString()
|
||||
: 'not persisted yet';
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'RemoteIngress Hub Settings',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-checkbox
|
||||
.key=${'enabled'}
|
||||
.label=${'Enable RemoteIngress Hub'}
|
||||
.value=${hubSettings?.enabled ?? false}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-text
|
||||
.key=${'tunnelPort'}
|
||||
.label=${'Tunnel Port'}
|
||||
.description=${'TCP/UDP port edges connect to on the hub.'}
|
||||
.value=${(hubSettings?.tunnelPort || 8443).toString()}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'hubDomain'}
|
||||
.label=${'Hub Domain / Address'}
|
||||
.description=${'Public host or IP embedded in edge connection tokens.'}
|
||||
.value=${hubSettings?.hubDomain || ''}
|
||||
></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.key=${'profile'}
|
||||
.label=${'Performance Profile'}
|
||||
@@ -662,8 +680,8 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
<p class="settingsNote">
|
||||
Saving restarts the RemoteIngress hub so connected edges reconnect and pick up the new defaults.
|
||||
Last updated: ${updatedAt} by ${this.riState.hubSettings?.updatedBy || 'default'}.
|
||||
Saving applies DB-backed hub settings. Enabling or disabling the hub restarts SmartProxy so tunneled traffic and route metadata stay consistent.
|
||||
Last updated: ${updatedAt} by ${hubSettings?.updatedBy || 'default'}.
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -679,9 +697,11 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
let performanceSettings: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
|
||||
let performanceSettings: interfaces.data.IRemoteIngressPerformanceConfig | null;
|
||||
let tunnelPort: number;
|
||||
try {
|
||||
performanceSettings = this.collectHubPerformanceSettings(formData);
|
||||
tunnelPort = this.parseRequiredPort(formData.tunnelPort, 'Tunnel Port');
|
||||
performanceSettings = this.collectHubPerformanceSettings(formData, performance);
|
||||
} catch (err: unknown) {
|
||||
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
|
||||
return;
|
||||
@@ -689,7 +709,12 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
|
||||
const nextState = await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.updateRemoteIngressHubSettingsAction,
|
||||
{ performance: performanceSettings },
|
||||
{
|
||||
enabled: formData.enabled !== false,
|
||||
tunnelPort,
|
||||
hubDomain: `${formData.hubDomain || ''}`.trim() || null,
|
||||
performance: performanceSettings,
|
||||
},
|
||||
);
|
||||
if (nextState.error) {
|
||||
DeesToast.show({ message: nextState.error, type: 'error', duration: 4000 });
|
||||
@@ -703,29 +728,37 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
private collectHubPerformanceSettings(formData: Record<string, any>): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
|
||||
const next: interfaces.data.IRemoteIngressPerformanceConfig = {};
|
||||
private collectHubPerformanceSettings(
|
||||
formData: Record<string, any>,
|
||||
currentPerformance: interfaces.data.IRemoteIngressPerformanceConfig,
|
||||
): interfaces.data.IRemoteIngressPerformanceConfig | null {
|
||||
const next: interfaces.data.IRemoteIngressPerformanceConfig = { ...currentPerformance };
|
||||
const profile = getDropdownKey(formData.profile) as interfaces.data.TRemoteIngressPerformanceProfile | '';
|
||||
if (profile) {
|
||||
next.profile = profile;
|
||||
} else {
|
||||
delete next.profile;
|
||||
}
|
||||
|
||||
this.assignPositiveIntegerSetting(next, 'maxStreamsPerEdge', formData.maxStreamsPerEdge, 'Max Connections / Edge');
|
||||
this.assignPositiveIntegerSetting(next, 'clientWriteTimeoutMs', formData.clientWriteTimeoutMs, 'Client Write Timeout');
|
||||
this.assignPositiveIntegerSetting(next, 'firstDataConnectTimeoutMs', formData.firstDataConnectTimeoutMs, 'First Data Timeout');
|
||||
this.assignOptionalPositiveIntegerSetting(next, 'maxStreamsPerEdge', formData.maxStreamsPerEdge, 'Max Connections / Edge');
|
||||
this.assignOptionalPositiveIntegerSetting(next, 'clientWriteTimeoutMs', formData.clientWriteTimeoutMs, 'Client Write Timeout');
|
||||
this.assignOptionalPositiveIntegerSetting(next, 'firstDataConnectTimeoutMs', formData.firstDataConnectTimeoutMs, 'First Data Timeout');
|
||||
|
||||
const serverFirstPorts = this.parsePortList(formData.serverFirstPorts, 'Server-first Ports');
|
||||
if (serverFirstPorts.length > 0) {
|
||||
const serverFirstPortsText = `${formData.serverFirstPorts || ''}`.trim();
|
||||
if (serverFirstPortsText) {
|
||||
const serverFirstPorts = this.parsePortList(serverFirstPortsText, 'Server-first Ports');
|
||||
if (serverFirstPorts.includes(443)) {
|
||||
throw new Error('Port 443 is client-first TLS and must not be listed as server-first');
|
||||
}
|
||||
next.serverFirstPorts = serverFirstPorts;
|
||||
} else {
|
||||
delete next.serverFirstPorts;
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
return Object.keys(next).length > 0 ? next : null;
|
||||
}
|
||||
|
||||
private assignPositiveIntegerSetting(
|
||||
private assignOptionalPositiveIntegerSetting(
|
||||
target: interfaces.data.IRemoteIngressPerformanceConfig,
|
||||
key: 'maxStreamsPerEdge' | 'clientWriteTimeoutMs' | 'firstDataConnectTimeoutMs',
|
||||
value: any,
|
||||
@@ -733,6 +766,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
): void {
|
||||
const text = `${value || ''}`.trim();
|
||||
if (!text) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(text, 10);
|
||||
@@ -755,4 +789,12 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
}
|
||||
return [...new Set(ports)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private parseRequiredPort(value: any, label: string): number {
|
||||
const port = Number.parseInt(`${value || ''}`.trim(), 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`${label} must be a valid port number`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
import type {
|
||||
IRoutePathClassOption as ISzRoutePathClassOption,
|
||||
IRouteSourcePolicyPreset as ISzRouteSourcePolicyPreset,
|
||||
ISourceProfileOption as ISzSourceProfileOption,
|
||||
SzInputRouteSourcePolicy,
|
||||
} from '@serve.zone/catalog';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -24,11 +30,8 @@ const tlsCertOptions = [
|
||||
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
|
||||
{ key: 'custom', option: 'Custom certificate' },
|
||||
];
|
||||
const sourcePolicyPresetOptions = [
|
||||
{ key: 'manual', option: 'Manual source policy' },
|
||||
{ key: 'gitea', option: 'Gitea bot protection' },
|
||||
];
|
||||
const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
|
||||
type TSzRouteSecurity = NonNullable<ISzSourceProfileOption['security']>;
|
||||
|
||||
function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
|
||||
return { enabled: true, maxRequests, window: 60, keyBy: 'ip' };
|
||||
@@ -38,38 +41,6 @@ function getDropdownKey(value: any): string {
|
||||
return typeof value === 'string' ? value : value?.key || '';
|
||||
}
|
||||
|
||||
function getSourcePolicyRefsFromFormData(formData: Record<string, any>): string[] {
|
||||
const refs: string[] = [];
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const ref = getDropdownKey(formData[`sourcePolicyProfileRef${index}`]);
|
||||
if (ref && !refs.includes(ref)) {
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function buildSourcePolicyMetadata(
|
||||
profileRefs: string[],
|
||||
existingSourcePolicy?: interfaces.data.IRouteSourcePolicy,
|
||||
): interfaces.data.IRouteSourcePolicy {
|
||||
return {
|
||||
bindings: profileRefs.map((sourceProfileRef) => {
|
||||
const existingBinding = existingSourcePolicy?.bindings.find((binding) => binding.sourceProfileRef === sourceProfileRef);
|
||||
return existingBinding
|
||||
? {
|
||||
...existingBinding,
|
||||
sourceProfileRef,
|
||||
onExceeded: existingBinding.onExceeded || { type: '429' as const },
|
||||
}
|
||||
: {
|
||||
sourceProfileRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]): {
|
||||
refs: string[];
|
||||
missingNames: string[];
|
||||
@@ -87,111 +58,145 @@ function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]):
|
||||
return { refs, missingNames };
|
||||
}
|
||||
|
||||
function buildGiteaSourcePolicyMetadata(profileRefs: string[]): interfaces.data.IRouteSourcePolicy {
|
||||
function buildGiteaSourceBindingsMetadata(profileRefs: string[]): interfaces.data.IRouteSourceBinding[] {
|
||||
const [trustedRef, aiRef, publicRef] = profileRefs;
|
||||
return {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: trustedRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: aiRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
pathPolicies: [
|
||||
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
|
||||
{ pathClass: 'static', rateLimit: rateLimit(240) },
|
||||
{ pathClass: 'raw', rateLimit: rateLimit(20) },
|
||||
{ pathClass: 'archive', rateLimit: rateLimit(6) },
|
||||
{ pathClass: 'expensive-html', rateLimit: rateLimit(6) },
|
||||
{ pathClass: 'normal-html', rateLimit: rateLimit(20) },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
pathPolicies: [
|
||||
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
|
||||
{ pathClass: 'static', rateLimit: rateLimit(600) },
|
||||
{ pathClass: 'raw', rateLimit: rateLimit(120) },
|
||||
{ pathClass: 'archive', rateLimit: rateLimit(30) },
|
||||
{ pathClass: 'expensive-html', rateLimit: rateLimit(30) },
|
||||
{ pathClass: 'normal-html', rateLimit: rateLimit(120) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return [
|
||||
{
|
||||
sourceProfileRef: trustedRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: aiRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
pathPolicies: [
|
||||
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
|
||||
{ pathClass: 'static', rateLimit: rateLimit(240) },
|
||||
{ pathClass: 'raw', rateLimit: rateLimit(20) },
|
||||
{ pathClass: 'archive', rateLimit: rateLimit(6) },
|
||||
{ pathClass: 'expensive-html', rateLimit: rateLimit(6) },
|
||||
{ pathClass: 'normal-html', rateLimit: rateLimit(20) },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
pathPolicies: [
|
||||
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
|
||||
{ pathClass: 'static', rateLimit: rateLimit(600) },
|
||||
{ pathClass: 'raw', rateLimit: rateLimit(120) },
|
||||
{ pathClass: 'archive', rateLimit: rateLimit(30) },
|
||||
{ pathClass: 'expensive-html', rateLimit: rateLimit(30) },
|
||||
{ pathClass: 'normal-html', rateLimit: rateLimit(120) },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getGiteaPresetSourcePolicy(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourcePolicy | null {
|
||||
function getGiteaSourcePolicyPresets(profiles: interfaces.data.ISourceProfile[]): ISzRouteSourcePolicyPreset[] {
|
||||
const { refs, missingNames } = getGiteaPresetProfileRefs(profiles);
|
||||
if (missingNames.length > 0) {
|
||||
alert(`Gitea source-policy preset needs these seeded profiles: ${missingNames.join(', ')}`);
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
if (!validateSourcePolicySelection(refs, profiles)) {
|
||||
return null;
|
||||
}
|
||||
return buildGiteaSourcePolicyMetadata(refs);
|
||||
return [
|
||||
{
|
||||
key: 'gitea-bot-protection',
|
||||
label: 'Gitea bot protection',
|
||||
description: 'TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC with path-class rate limits.',
|
||||
bindings: buildGiteaSourceBindingsMetadata(refs),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function metadataUsesPathPolicies(metadata?: interfaces.data.IRouteMetadata): boolean {
|
||||
return Boolean(metadata?.sourcePolicy?.bindings.some((binding) => binding.pathPolicies?.length));
|
||||
function normalizeSecurityListEntries(entries: unknown): string[] {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.map((entry) => {
|
||||
if (typeof entry === 'string') return entry.trim();
|
||||
if (entry && typeof entry === 'object' && 'ip' in entry) {
|
||||
const ip = (entry as Record<string, unknown>).ip;
|
||||
return typeof ip === 'string' ? ip.trim() : '';
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean {
|
||||
return (profile.security?.ipAllowList || []).some((entry) => {
|
||||
const source = typeof entry === 'string' ? entry : entry.ip;
|
||||
return normalizeSecurityListEntries(profile.security?.ipAllowList).some((source) => {
|
||||
return ['*', '0.0.0.0/0', '::/0'].includes(source.trim());
|
||||
});
|
||||
}
|
||||
|
||||
function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile): boolean {
|
||||
return (profile.security?.ipAllowList || []).some((entry) => {
|
||||
const source = typeof entry === 'string' ? entry : entry.ip;
|
||||
return source.trim().length > 0;
|
||||
return normalizeSecurityListEntries(profile.security?.ipAllowList).length > 0;
|
||||
}
|
||||
|
||||
function normalizeCatalogRateLimit(
|
||||
rateLimitValue: interfaces.data.IRouteSecurity['rateLimit'] | undefined,
|
||||
): TSzRouteSecurity['rateLimit'] | undefined {
|
||||
if (!rateLimitValue) return undefined;
|
||||
return {
|
||||
enabled: Boolean(rateLimitValue.enabled),
|
||||
maxRequests: Number(rateLimitValue.maxRequests) || 0,
|
||||
window: Number(rateLimitValue.window) || 0,
|
||||
...(rateLimitValue.keyBy ? { keyBy: String(rateLimitValue.keyBy) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getSourceProfileOptions(profiles: interfaces.data.ISourceProfile[]): ISzSourceProfileOption[] {
|
||||
return profiles.map((profile) => {
|
||||
const ipAllowList = normalizeSecurityListEntries(profile.security?.ipAllowList);
|
||||
const ipBlockList = normalizeSecurityListEntries(profile.security?.ipBlockList);
|
||||
const rateLimitValue = normalizeCatalogRateLimit(profile.security?.rateLimit);
|
||||
const security: TSzRouteSecurity = {
|
||||
...(ipAllowList.length ? { ipAllowList } : {}),
|
||||
...(ipBlockList.length ? { ipBlockList } : {}),
|
||||
...(typeof profile.security?.maxConnections === 'number' ? { maxConnections: profile.security.maxConnections } : {}),
|
||||
...(rateLimitValue ? { rateLimit: rateLimitValue } : {}),
|
||||
};
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
description: profile.description,
|
||||
security,
|
||||
hasSourceMatches: sourceProfileHasSourceMatches(profile),
|
||||
matchesAllSources: sourceProfileMatchesAll(profile),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateSourcePolicySelection(
|
||||
profileRefs: string[],
|
||||
profiles: interfaces.data.ISourceProfile[],
|
||||
): boolean {
|
||||
if (profileRefs.length === 0) {
|
||||
function getRoutePathClassOptions(): ISzRoutePathClassOption[] {
|
||||
return interfaces.data.routePathClasses.map((pathClass) => ({
|
||||
key: pathClass,
|
||||
label: interfaces.data.giteaRoutePathClassLabels[pathClass],
|
||||
defaultPatterns: interfaces.data.giteaRoutePathClassPatterns[pathClass],
|
||||
}));
|
||||
}
|
||||
|
||||
function getSourcePolicyInfoText(profiles: interfaces.data.ISourceProfile[]): string {
|
||||
const { missingNames } = getGiteaPresetProfileRefs(profiles);
|
||||
const presetText = missingNames.length > 0
|
||||
? `Gitea preset hidden until these source profiles exist: ${missingNames.join(', ')}.`
|
||||
: 'Use the Gitea preset as a starting point, then edit the generated bindings before saving.';
|
||||
return `First matching source profile wins. Leave empty for no route-level source access control. ${presetText}`;
|
||||
}
|
||||
|
||||
function validateSourcePolicyInput(form: Element): boolean {
|
||||
const sourcePolicyInput = form.querySelector('sz-input-route-source-policy') as SzInputRouteSourcePolicy | null;
|
||||
if (!sourcePolicyInput || sourcePolicyInput.isValid()) {
|
||||
return true;
|
||||
}
|
||||
alert(sourcePolicyInput.getValidationMessages().join('\n'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedProfiles = profileRefs
|
||||
.map((profileRef) => profiles.find((profile) => profile.id === profileRef))
|
||||
.filter(Boolean) as interfaces.data.ISourceProfile[];
|
||||
|
||||
if (selectedProfiles.length !== profileRefs.length) {
|
||||
alert('One or more selected source profiles could not be found. Refresh profiles and try again.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const profilesWithoutMatches = selectedProfiles.filter((profile) => !sourceProfileHasSourceMatches(profile));
|
||||
if (profilesWithoutMatches.length > 0) {
|
||||
alert(`Source profiles need IP/CIDR match entries before use: ${profilesWithoutMatches.map((profile) => profile.name).join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fallbackProfile = selectedProfiles[selectedProfiles.length - 1];
|
||||
if (!sourceProfileMatchesAll(fallbackProfile)) {
|
||||
alert('Source policy needs an explicit public/wildcard fallback profile as the last binding. Add a profile with IP Allow List "*".');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedProfiles.slice(0, -1).some((profile) => sourceProfileMatchesAll(profile))) {
|
||||
alert('Wildcard source profiles must be last. Earlier wildcard profiles would shadow all following profiles.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fallbackProfile.security?.rateLimit?.enabled !== true) {
|
||||
return confirm(`The fallback profile "${fallbackProfile.name}" has no enabled rate limit. Save anyway?`);
|
||||
}
|
||||
|
||||
return true;
|
||||
function getSourceBindingsFromFormData(formData: Record<string, unknown>): interfaces.data.IRouteSourceBinding[] {
|
||||
const sourceBindings = formData.sourceBindings;
|
||||
return Array.isArray(sourceBindings)
|
||||
? sourceBindings as interfaces.data.IRouteSourceBinding[]
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseTargetPort(value: any): number | undefined {
|
||||
@@ -521,7 +526,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
const meta = merged.metadata;
|
||||
const isSystemManaged = this.isSystemManagedRoute(merged);
|
||||
const sourcePolicySummary = this.describeSourcePolicy(meta);
|
||||
const sourceBindingSummary = this.describeSourcePolicy(meta);
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
@@ -531,7 +536,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
|
||||
<p>ID: <code style="color: #888;">${merged.id}</code></p>
|
||||
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
|
||||
${sourcePolicySummary ? html`<p>Source Policy: <strong style="color: #a78bfa;">${sourcePolicySummary}</strong></p>` : ''}
|
||||
${sourceBindingSummary ? html`<p>Source Bindings: <strong style="color: #a78bfa;">${sourceBindingSummary}</strong></p>` : ''}
|
||||
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
|
||||
</div>
|
||||
`,
|
||||
@@ -632,13 +637,6 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const profiles = this.profilesTargetsState.profiles;
|
||||
const targets = this.profilesTargetsState.targets;
|
||||
|
||||
const profileOptions = [
|
||||
{ key: '', option: '(none — inline security)' },
|
||||
...profiles.map((p) => ({
|
||||
key: p.id,
|
||||
option: `${p.name}${p.description ? ' — ' + p.description : ''}`,
|
||||
})),
|
||||
];
|
||||
const targetOptions = [
|
||||
{ key: '', option: '(none — inline target)' },
|
||||
...targets.map((t) => ({
|
||||
@@ -663,8 +661,10 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const currentVpnOnly = route.vpnOnly === true;
|
||||
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
|
||||
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
|
||||
const currentSourcePolicyRefs = this.getSourcePolicyRefs(merged.metadata);
|
||||
const currentSourcePolicyPreset = metadataUsesPathPolicies(merged.metadata) ? 'gitea' : 'manual';
|
||||
const sourceProfileOptions = getSourceProfileOptions(profiles);
|
||||
const pathClassOptions = getRoutePathClassOptions();
|
||||
const sourcePolicyPresets = getGiteaSourcePolicyPresets(profiles);
|
||||
const sourcePolicyInfoText = getSourcePolicyInfoText(profiles);
|
||||
|
||||
// Compute current TLS state for pre-population
|
||||
const currentTls = (route.action as any).tls;
|
||||
@@ -685,25 +685,15 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
|
||||
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
|
||||
<strong>Source Policy</strong>
|
||||
<small>First matching profile wins. Exceeded limits return 429 and do not fall through.</small>
|
||||
<dees-input-dropdown
|
||||
.key=${'sourcePolicyPreset'}
|
||||
.label=${'Source Policy Preset'}
|
||||
.options=${sourcePolicyPresetOptions}
|
||||
.selectedOption=${sourcePolicyPresetOptions.find((o) => o.key === currentSourcePolicyPreset) || sourcePolicyPresetOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
<small>Gitea preset uses TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and applies path-class limits.</small>
|
||||
${[0, 1, 2, 3].map((index) => html`
|
||||
<dees-input-dropdown
|
||||
.key=${`sourcePolicyProfileRef${index}`}
|
||||
.label=${`Source Profile ${index + 1}`}
|
||||
.options=${profileOptions}
|
||||
.selectedOption=${profileOptions.find((o) => o.key === (currentSourcePolicyRefs[index] || '')) || profileOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
`)}
|
||||
</div>
|
||||
<sz-input-route-source-policy
|
||||
.key=${'sourceBindings'}
|
||||
.label=${'Source Policy'}
|
||||
.infoText=${sourcePolicyInfoText}
|
||||
.sourceProfiles=${sourceProfileOptions}
|
||||
.pathClassOptions=${pathClassOptions}
|
||||
.presets=${sourcePolicyPresets}
|
||||
.value=${merged.metadata?.sourceBindings || []}
|
||||
></sz-input-route-source-policy>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
||||
@@ -737,6 +727,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
if (!formData.name || !formData.ports) return;
|
||||
if (!validateSourcePolicyInput(form)) return;
|
||||
|
||||
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
|
||||
const domains: string[] = Array.isArray(formData.domains)
|
||||
@@ -744,11 +735,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
: [];
|
||||
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
|
||||
|
||||
const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
|
||||
const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
|
||||
? []
|
||||
: getSourcePolicyRefsFromFormData(formData);
|
||||
if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
|
||||
const sourceBindings = getSourceBindingsFromFormData(formData);
|
||||
const targetKey = getDropdownKey(formData.networkTargetRef);
|
||||
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
|
||||
const targetPort = preserveMatchPort
|
||||
@@ -812,20 +799,10 @@ export class OpsViewRoutes extends DeesElement {
|
||||
}
|
||||
|
||||
const metadata: any = {};
|
||||
if (sourcePolicyPreset === 'gitea') {
|
||||
const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
|
||||
if (!sourcePolicy) return;
|
||||
metadata.sourcePolicy = sourcePolicy;
|
||||
metadata.sourceProfileRef = '';
|
||||
metadata.sourceProfileName = '';
|
||||
} else if (sourcePolicyRefs.length > 0) {
|
||||
metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs, merged.metadata?.sourcePolicy);
|
||||
metadata.sourceProfileRef = '';
|
||||
metadata.sourceProfileName = '';
|
||||
} else if (merged.metadata?.sourcePolicy || merged.metadata?.sourceProfileRef) {
|
||||
metadata.sourcePolicy = { bindings: [] };
|
||||
metadata.sourceProfileRef = '';
|
||||
metadata.sourceProfileName = '';
|
||||
if (sourceBindings.length > 0) {
|
||||
metadata.sourceBindings = sourceBindings;
|
||||
} else if (merged.metadata?.sourceBindings) {
|
||||
metadata.sourceBindings = [];
|
||||
}
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
@@ -861,14 +838,11 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const profiles = this.profilesTargetsState.profiles;
|
||||
const targets = this.profilesTargetsState.targets;
|
||||
|
||||
// Build dropdown options for profiles and targets
|
||||
const profileOptions = [
|
||||
{ key: '', option: '(none — inline security)' },
|
||||
...profiles.map((p) => ({
|
||||
key: p.id,
|
||||
option: `${p.name}${p.description ? ' — ' + p.description : ''}`,
|
||||
})),
|
||||
];
|
||||
// Build dropdown options for targets and source policy metadata
|
||||
const sourceProfileOptions = getSourceProfileOptions(profiles);
|
||||
const pathClassOptions = getRoutePathClassOptions();
|
||||
const sourcePolicyPresets = getGiteaSourcePolicyPresets(profiles);
|
||||
const sourcePolicyInfoText = getSourcePolicyInfoText(profiles);
|
||||
const targetOptions = [
|
||||
{ key: '', option: '(none — inline target)' },
|
||||
...targets.map((t) => ({
|
||||
@@ -885,25 +859,15 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
|
||||
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
|
||||
<strong>Source Policy</strong>
|
||||
<small>First matching profile wins. Exceeded limits return 429 and do not fall through.</small>
|
||||
<dees-input-dropdown
|
||||
.key=${'sourcePolicyPreset'}
|
||||
.label=${'Source Policy Preset'}
|
||||
.options=${sourcePolicyPresetOptions}
|
||||
.selectedOption=${sourcePolicyPresetOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
<small>Gitea preset uses TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and applies path-class limits.</small>
|
||||
${[0, 1, 2, 3].map((index) => html`
|
||||
<dees-input-dropdown
|
||||
.key=${`sourcePolicyProfileRef${index}`}
|
||||
.label=${`Source Profile ${index + 1}`}
|
||||
.options=${profileOptions}
|
||||
.selectedOption=${profileOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
`)}
|
||||
</div>
|
||||
<sz-input-route-source-policy
|
||||
.key=${'sourceBindings'}
|
||||
.label=${'Source Policy'}
|
||||
.infoText=${sourcePolicyInfoText}
|
||||
.sourceProfiles=${sourceProfileOptions}
|
||||
.pathClassOptions=${pathClassOptions}
|
||||
.presets=${sourcePolicyPresets}
|
||||
.value=${[]}
|
||||
></sz-input-route-source-policy>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
||||
@@ -937,6 +901,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
if (!formData.name || !formData.ports) return;
|
||||
if (!validateSourcePolicyInput(form)) return;
|
||||
|
||||
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
|
||||
const domains: string[] = Array.isArray(formData.domains)
|
||||
@@ -944,11 +909,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
: [];
|
||||
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
|
||||
|
||||
const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
|
||||
const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
|
||||
? []
|
||||
: getSourcePolicyRefsFromFormData(formData);
|
||||
if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
|
||||
const sourceBindings = getSourceBindingsFromFormData(formData);
|
||||
const targetKey = getDropdownKey(formData.networkTargetRef);
|
||||
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
|
||||
const targetPort = preserveMatchPort
|
||||
@@ -1013,12 +974,8 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
// Build metadata if profile/target selected
|
||||
const metadata: any = {};
|
||||
if (sourcePolicyPreset === 'gitea') {
|
||||
const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
|
||||
if (!sourcePolicy) return;
|
||||
metadata.sourcePolicy = sourcePolicy;
|
||||
} else if (sourcePolicyRefs.length > 0) {
|
||||
metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs);
|
||||
if (sourceBindings.length > 0) {
|
||||
metadata.sourceBindings = sourceBindings;
|
||||
}
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
@@ -1049,23 +1006,20 @@ export class OpsViewRoutes extends DeesElement {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
|
||||
private getSourcePolicyRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
|
||||
const policyRefs = metadata?.sourcePolicy?.bindings
|
||||
private getSourceBindingRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
|
||||
const bindingRefs = metadata?.sourceBindings
|
||||
?.map((binding) => binding.sourceProfileRef)
|
||||
.filter(Boolean) || [];
|
||||
if (policyRefs.length > 0) {
|
||||
return policyRefs;
|
||||
}
|
||||
return metadata?.sourceProfileRef ? [metadata.sourceProfileRef] : [];
|
||||
return bindingRefs;
|
||||
}
|
||||
|
||||
private describeSourcePolicy(metadata?: interfaces.data.IRouteMetadata): string {
|
||||
const refs = this.getSourcePolicyRefs(metadata);
|
||||
const refs = this.getSourceBindingRefs(metadata);
|
||||
if (refs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return refs.map((ref) => {
|
||||
const binding = metadata?.sourcePolicy?.bindings?.find((item) => item.sourceProfileRef === ref);
|
||||
const binding = metadata?.sourceBindings?.find((item) => item.sourceProfileRef === ref);
|
||||
const profile = this.profilesTargetsState.profiles.find((item) => item.id === ref);
|
||||
return binding?.sourceProfileName || profile?.name || ref.slice(0, 8);
|
||||
}).join(' → ');
|
||||
|
||||
Reference in New Issue
Block a user