@serve.zone/dcrouter 14.2.3 → 14.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +1120 -1058
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/config/classes.route-config-manager.d.ts +2 -0
- package/dist_ts/config/classes.route-config-manager.js +66 -6
- package/dist_ts/config/classes.source-policy-compiler.d.ts +2 -0
- package/dist_ts/config/classes.source-policy-compiler.js +121 -16
- package/dist_ts/dns/manager.dns.js +14 -3
- package/dist_ts/http3/http3-route-augmentation.d.ts +1 -0
- package/dist_ts/http3/http3-route-augmentation.js +9 -2
- package/dist_ts_interfaces/data/route-management.d.ts +17 -2
- package/dist_ts_interfaces/data/route-management.js +1 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/network/ops-view-routes.js +18 -3
- package/dist_ts_web/elements/network/ops-view-sourceprofiles.js +111 -11
- package/package.json +3 -3
- package/readme.md +70 -8
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/config/classes.route-config-manager.ts +71 -5
- package/ts/config/classes.source-policy-compiler.ts +140 -22
- package/ts/dns/manager.dns.ts +17 -2
- package/ts/http3/http3-route-augmentation.ts +11 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/network/ops-view-routes.ts +30 -5
- package/ts_web/elements/network/ops-view-sourceprofiles.ts +112 -11
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serve.zone/dcrouter",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "14.
|
|
4
|
+
"version": "14.3.1",
|
|
5
5
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@push.rocks/smartnetwork": "^4.7.2",
|
|
52
52
|
"@push.rocks/smartpath": "^6.0.0",
|
|
53
53
|
"@push.rocks/smartpromise": "^4.2.4",
|
|
54
|
-
"@push.rocks/smartproxy": "^27.
|
|
54
|
+
"@push.rocks/smartproxy": "^27.15.0",
|
|
55
55
|
"@push.rocks/smartradius": "^1.3.0",
|
|
56
56
|
"@push.rocks/smartrequest": "^5.0.3",
|
|
57
57
|
"@push.rocks/smartrx": "^3.0.10",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"@push.rocks/smartunique": "^3.0.9",
|
|
60
60
|
"@push.rocks/smartvpn": "1.20.0",
|
|
61
61
|
"@push.rocks/taskbuffer": "^8.0.2",
|
|
62
|
-
"@serve.zone/catalog": "^2.
|
|
62
|
+
"@serve.zone/catalog": "^2.14.0",
|
|
63
63
|
"@serve.zone/interfaces": "^6.3.0",
|
|
64
64
|
"@serve.zone/remoteingress": "^4.23.0",
|
|
65
65
|
"@tsclass/tsclass": "^9.5.1",
|
package/readme.md
CHANGED
|
@@ -12,24 +12,25 @@ Modern infrastructure often has too many tiny edge tools: a proxy here, a DNS da
|
|
|
12
12
|
|
|
13
13
|
Highlights:
|
|
14
14
|
|
|
15
|
-
- 🌐 SmartProxy-backed HTTP, HTTPS, TCP, TLS/SNI, and optional HTTP/3 route handling
|
|
15
|
+
- 🌐 SmartProxy-backed HTTP, HTTPS, TCP, TLS/SNI, source-policy rate limits/challenges, and optional HTTP/3 route handling
|
|
16
16
|
- 📬 SmartMTA-backed SMTP ingress and email-domain operations
|
|
17
17
|
- 🧭 SmartDNS-backed authoritative DNS plus generated DNS-over-HTTPS routes
|
|
18
|
-
- 🔐 ACME, certificate state, API tokens, users, source profiles, target profiles
|
|
19
|
-
- 🛡️ RADIUS, VLAN assignment, VPN-protected routes, and remote ingress firewall snapshots
|
|
18
|
+
- 🔐 ACME with managed-domain DNS-01 support, certificate state, API tokens, users, source profiles, and target profiles
|
|
19
|
+
- 🛡️ RADIUS, VLAN assignment, VPN-protected routes, IP intelligence, block rules, and remote ingress firewall snapshots
|
|
20
20
|
- 🖥️ Browser Ops dashboard and TypedRequest API served by the built-in OpsServer
|
|
21
21
|
|
|
22
22
|
## Runtime Areas
|
|
23
23
|
|
|
24
24
|
| Area | What dcrouter manages |
|
|
25
25
|
| --- | --- |
|
|
26
|
-
| Proxying | SmartProxy routes for HTTP, HTTPS, TCP, SNI, TLS termination, passthrough,
|
|
26
|
+
| Proxying | SmartProxy routes for HTTP, HTTPS, TCP, SNI, TLS termination, passthrough, backend forwarding, source policies, rate limits, and browser challenges |
|
|
27
27
|
| Route ownership | Constructor routes, generated email/DNS routes, and API-created routes with explicit origins |
|
|
28
28
|
| DNS | Authoritative scopes, generated NS records, static DNS records, provider-backed domains, and DoH endpoints |
|
|
29
29
|
| Email | UnifiedEmailServer startup, email-domain management, route-backed delivery actions, received mail operations |
|
|
30
|
-
| Certificates | ACME config, stored certificate metadata, provisioning backoff, and certificate status reporting |
|
|
30
|
+
| Certificates | ACME config, managed-domain DNS-01 challenges, HTTP-01 fallback, stored certificate metadata, provisioning backoff, and certificate status reporting |
|
|
31
31
|
| Edge access | Remote ingress hub, edge registrations, derived edge ports, pushed firewall rules, VPN-only route access |
|
|
32
32
|
| Network auth | RADIUS clients, MAC Authentication Bypass, VLAN mapping, and accounting sessions |
|
|
33
|
+
| Security policy | DB-backed block rules, public-IP intelligence, compiled SmartProxy deny policy, RemoteIngress firewall snapshots, and audit events |
|
|
33
34
|
| Operations | Dashboard views, TypedRequest handlers, metrics, logs, health, API tokens, users, and configuration views |
|
|
34
35
|
|
|
35
36
|
## Install
|
|
@@ -150,18 +151,25 @@ System routes are persisted with stable `systemKey` values. API-created routes a
|
|
|
150
151
|
|
|
151
152
|
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.
|
|
152
153
|
|
|
154
|
+
Source profiles store reusable defaults only. A source profile is not enforced globally by itself; dcrouter compiles it into SmartProxy routes only when a route references it through `metadata.sourceBindings[]`.
|
|
155
|
+
|
|
153
156
|
Runtime behavior:
|
|
154
157
|
|
|
155
158
|
- Source matching uses the referenced `SourceProfile.security.ipAllowList`.
|
|
156
159
|
- Bindings are evaluated in order and the first matching source profile wins.
|
|
157
|
-
- A matched binding that exceeds its configured rate or connection limit is terminal
|
|
160
|
+
- A matched binding that exceeds its configured rate or connection limit is terminal; dcrouter does not fall through to later bindings. Rate limits normally return `429`, or trigger a browser challenge when `rateLimit.onExceeded.type === 'challenge'`.
|
|
158
161
|
- Source-binding rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-binding and path-policy overrides.
|
|
162
|
+
- Source-binding and path-policy `rateLimit` and `challenge` fields use tri-state semantics: omitted means inherit, `null` means explicitly clear inherited protection, and an object means custom protection.
|
|
163
|
+
- Effective protection precedence is path policy, then route binding, then source profile, then parent source profile, then no protection.
|
|
164
|
+
- When `sourceBindings[]` are present, dcrouter compiles source-policy variants from source profile, binding, and path policy protection. Base route `rateLimit` and `challenge` values are not part of that inheritance chain; routes without source bindings use normal SmartProxy route security.
|
|
165
|
+
- Direct `challenge` protection applies to matching HTTP-visible requests. `rateLimit.onExceeded.type === 'challenge'` configures a browser challenge for rate-limit exceed events.
|
|
166
|
+
- Binding-level and path-policy `onExceeded` is only for `429` message handling. Browser challenge-on-exceeded behavior belongs on `rateLimit.onExceeded`.
|
|
159
167
|
- Private-only binding lists are valid. dcrouter adds a same-match terminal deny fallback so unmatched sources fail closed.
|
|
160
168
|
- 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`.
|
|
161
169
|
- 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.
|
|
162
170
|
- 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.
|
|
163
171
|
|
|
164
|
-
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.
|
|
172
|
+
Path policies let a source binding override rate limits, browser challenges, 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.
|
|
165
173
|
|
|
166
174
|
```typescript
|
|
167
175
|
const trustedProfileId = 'source-profile-id-trusted';
|
|
@@ -181,6 +189,7 @@ const createRoutePayload = {
|
|
|
181
189
|
sourceBindings: [
|
|
182
190
|
{
|
|
183
191
|
sourceProfileRef: trustedProfileId,
|
|
192
|
+
rateLimit: null,
|
|
184
193
|
maxConnections: 5000,
|
|
185
194
|
onExceeded: { type: '429' },
|
|
186
195
|
},
|
|
@@ -210,7 +219,22 @@ const createRoutePayload = {
|
|
|
210
219
|
},
|
|
211
220
|
{
|
|
212
221
|
pathClass: 'normal-html',
|
|
213
|
-
rateLimit: {
|
|
222
|
+
rateLimit: {
|
|
223
|
+
enabled: true,
|
|
224
|
+
maxRequests: 120,
|
|
225
|
+
window: 60,
|
|
226
|
+
keyBy: 'ip',
|
|
227
|
+
onExceeded: {
|
|
228
|
+
type: 'challenge',
|
|
229
|
+
challenge: {
|
|
230
|
+
providerId: 'smartchallenge',
|
|
231
|
+
challengeType: 'wait',
|
|
232
|
+
applyTo: { methods: ['GET'], browserNavigationsOnly: true },
|
|
233
|
+
clearance: { ttlSeconds: 300, bindToHost: true, bindToRoute: true },
|
|
234
|
+
},
|
|
235
|
+
clearanceEffect: 'bypass-rate-limit',
|
|
236
|
+
},
|
|
237
|
+
},
|
|
214
238
|
},
|
|
215
239
|
],
|
|
216
240
|
},
|
|
@@ -219,6 +243,44 @@ const createRoutePayload = {
|
|
|
219
243
|
};
|
|
220
244
|
```
|
|
221
245
|
|
|
246
|
+
### Source Profiles
|
|
247
|
+
|
|
248
|
+
Source profiles are reusable source-side security defaults. They can carry `ipAllowList`, `ipBlockList`, `maxConnections`, `rateLimit`, authentication fields, VPN fields, and `challenge`. Profiles can extend parent profiles; IP allow/block lists are unioned and scalar/object protection fields such as `maxConnections`, `rateLimit`, and `challenge` are overridden by the more specific profile.
|
|
249
|
+
|
|
250
|
+
When `dbConfig.seedOnEmpty` seeds an empty database, dcrouter's built-in profile set includes:
|
|
251
|
+
|
|
252
|
+
| Profile | Default purpose |
|
|
253
|
+
| --- | --- |
|
|
254
|
+
| `TRUSTED NETWORKS` | Private networks, localhost, and high connection allowance |
|
|
255
|
+
| `AI CRAWLERS` | Placeholder crawler profile with low per-IP request limits until verified crawler CIDRs are added |
|
|
256
|
+
| `PUBLIC` | Public fallback profile with per-IP request limiting |
|
|
257
|
+
| `STANDARD` | Standard private-network access profile |
|
|
258
|
+
|
|
259
|
+
The source profile dashboard supports protection modes for rate limits and browser challenges: inherit/unset, none/clear inherited, or custom. Custom challenge settings expose the provider id, challenge type, and clearance TTL used by source-policy route compilation.
|
|
260
|
+
|
|
261
|
+
### Challenge Support
|
|
262
|
+
|
|
263
|
+
dcrouter registers the `smartchallenge` provider with the `wait` challenge type when SmartProxy starts. Source policies can apply challenges in two ways:
|
|
264
|
+
|
|
265
|
+
- `challenge` on a source profile, route binding, or path policy protects matching HTTP-visible traffic directly.
|
|
266
|
+
- `rateLimit.onExceeded.type: 'challenge'` configures the rate-limit exceeded path to issue the configured challenge.
|
|
267
|
+
|
|
268
|
+
Challenge-aware source-policy variants and HTTP/3-augmented challenged routes force HTTP protocol matching where needed so SmartProxy can process browser challenges correctly.
|
|
269
|
+
|
|
270
|
+
### ACME And Certificate Challenges
|
|
271
|
+
|
|
272
|
+
DB-backed ACME configuration drives SmartProxy certificate provisioning. When ACME is enabled and dcrouter has managed domains, `DnsManager` builds the DNS-01 provider used by SmartAcme. That provider creates and removes challenge TXT records through the same DNS record path used for dcrouter-hosted zones and provider-managed domains.
|
|
273
|
+
|
|
274
|
+
SmartAcme is configured with DNS-01 priority for managed domains. If SmartAcme is still starting or retrying account setup, the certificate provision callback falls back to HTTP-01 for that request. Issued certificates are stored through the proxy certificate store, and certificate status is tracked from both newly issued and store-loaded certificate events.
|
|
275
|
+
|
|
276
|
+
### Security Policy And IP Intelligence
|
|
277
|
+
|
|
278
|
+
When DB-backed persistence is enabled, `SecurityPolicyManager` maintains global deny policy from block rules and observed public-IP intelligence. Rule types are `ip`, `cidr`, `asn`, and `organization`; organization rules support `exact` or `contains` matching against enriched ASN and registrant organization data.
|
|
279
|
+
|
|
280
|
+
The compiled policy contains `blockedIps` and `blockedCidrs`. dcrouter merges it into SmartProxy's security policy and also compiles an IPv4 firewall snapshot for RemoteIngress edge synchronization. Security policy changes are audited when block rules are created, updated, or deleted.
|
|
281
|
+
|
|
282
|
+
The OpsServer exposes these security policy methods through TypedRequest: `listSecurityBlockRules`, `createSecurityBlockRule`, `updateSecurityBlockRule`, `deleteSecurityBlockRule`, `listIpIntelligence`, `refreshIpIntelligence`, `getCompiledSecurityPolicy`, and `listSecurityPolicyAudit`.
|
|
283
|
+
|
|
222
284
|
## Production-Flavored Example
|
|
223
285
|
|
|
224
286
|
```typescript
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -11,9 +11,10 @@ import type {
|
|
|
11
11
|
IRoutePathPolicyBinding,
|
|
12
12
|
IRouteSourceBinding,
|
|
13
13
|
IRouteSecurity,
|
|
14
|
+
TRouteRateLimitExceededPolicy,
|
|
14
15
|
} from '../../ts_interfaces/data/route-management.js';
|
|
15
16
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
|
16
|
-
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
|
17
|
+
import { type IHttp3Config, augmentRouteWithHttp3, routeNeedsHttpProtocol } from '../http3/index.js';
|
|
17
18
|
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
|
18
19
|
import { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
|
|
19
20
|
import { deriveHttpRedirects } from './helpers.http-redirects.js';
|
|
@@ -548,6 +549,7 @@ export class RouteConfigManager {
|
|
|
548
549
|
continue;
|
|
549
550
|
}
|
|
550
551
|
const normalizedRateLimit = this.normalizeRateLimit(binding.rateLimit);
|
|
552
|
+
const normalizedChallenge = this.normalizeChallenge(binding.challenge);
|
|
551
553
|
const normalizedPathPolicies = this.normalizePathPolicies(binding.pathPolicies);
|
|
552
554
|
|
|
553
555
|
normalizedBindings.push({
|
|
@@ -556,7 +558,8 @@ export class RouteConfigManager {
|
|
|
556
558
|
...(typeof binding.sourceProfileName === 'string' && binding.sourceProfileName.trim()
|
|
557
559
|
? { sourceProfileName: binding.sourceProfileName.trim() }
|
|
558
560
|
: {}),
|
|
559
|
-
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
|
|
561
|
+
...(normalizedRateLimit !== undefined ? { rateLimit: normalizedRateLimit } : {}),
|
|
562
|
+
...(normalizedChallenge !== undefined ? { challenge: normalizedChallenge } : {}),
|
|
560
563
|
...(typeof binding.maxConnections === 'number' && Number.isFinite(binding.maxConnections) && binding.maxConnections >= 0
|
|
561
564
|
? { maxConnections: binding.maxConnections }
|
|
562
565
|
: {}),
|
|
@@ -592,6 +595,7 @@ export class RouteConfigManager {
|
|
|
592
595
|
}
|
|
593
596
|
|
|
594
597
|
const normalizedRateLimit = this.normalizeRateLimit(pathPolicy.rateLimit);
|
|
598
|
+
const normalizedChallenge = this.normalizeChallenge(pathPolicy.challenge);
|
|
595
599
|
const pathPatterns = Array.isArray(pathPolicy.pathPatterns)
|
|
596
600
|
? [...new Set(pathPolicy.pathPatterns
|
|
597
601
|
.map((pattern) => typeof pattern === 'string' ? pattern.trim() : '')
|
|
@@ -602,7 +606,8 @@ export class RouteConfigManager {
|
|
|
602
606
|
...(typeof pathPolicy.id === 'string' && pathPolicy.id.trim() ? { id: pathPolicy.id.trim() } : {}),
|
|
603
607
|
pathClass: pathPolicy.pathClass,
|
|
604
608
|
...(pathPatterns?.length ? { pathPatterns } : {}),
|
|
605
|
-
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
|
|
609
|
+
...(normalizedRateLimit !== undefined ? { rateLimit: normalizedRateLimit } : {}),
|
|
610
|
+
...(normalizedChallenge !== undefined ? { challenge: normalizedChallenge } : {}),
|
|
606
611
|
...(typeof pathPolicy.maxConnections === 'number' && Number.isFinite(pathPolicy.maxConnections) && pathPolicy.maxConnections >= 0
|
|
607
612
|
? { maxConnections: pathPolicy.maxConnections }
|
|
608
613
|
: {}),
|
|
@@ -634,24 +639,75 @@ export class RouteConfigManager {
|
|
|
634
639
|
}
|
|
635
640
|
|
|
636
641
|
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
|
|
642
|
+
if (rateLimit === null) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
637
645
|
if (!rateLimit || typeof rateLimit !== 'object') {
|
|
638
646
|
return undefined;
|
|
639
647
|
}
|
|
648
|
+
if (rateLimit.enabled === false) {
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
640
651
|
|
|
641
652
|
const maxRequests = Number(rateLimit.maxRequests);
|
|
642
653
|
const window = Number(rateLimit.window);
|
|
643
|
-
if (!Number.isFinite(maxRequests) || maxRequests
|
|
654
|
+
if (!Number.isFinite(maxRequests) || maxRequests <= 0 || !Number.isFinite(window) || window <= 0) {
|
|
644
655
|
return undefined;
|
|
645
656
|
}
|
|
646
657
|
|
|
658
|
+
const onExceeded = this.normalizeRateLimitExceeded(rateLimit.onExceeded);
|
|
647
659
|
return {
|
|
648
|
-
enabled:
|
|
660
|
+
enabled: true,
|
|
649
661
|
maxRequests,
|
|
650
662
|
window,
|
|
651
663
|
keyBy: 'ip',
|
|
652
664
|
...(typeof rateLimit.errorMessage === 'string' && rateLimit.errorMessage.trim()
|
|
653
665
|
? { errorMessage: rateLimit.errorMessage.trim() }
|
|
654
666
|
: {}),
|
|
667
|
+
...(onExceeded ? { onExceeded } : {}),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private normalizeRateLimitExceeded(
|
|
672
|
+
onExceeded: TRouteRateLimitExceededPolicy | undefined,
|
|
673
|
+
): TRouteRateLimitExceededPolicy | undefined {
|
|
674
|
+
if (!onExceeded || typeof onExceeded !== 'object') {
|
|
675
|
+
return undefined;
|
|
676
|
+
}
|
|
677
|
+
const rawExceeded = onExceeded;
|
|
678
|
+
if (rawExceeded.type === 'challenge') {
|
|
679
|
+
const challenge = this.normalizeChallenge(rawExceeded.challenge);
|
|
680
|
+
if (!challenge || challenge === null) {
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
type: 'challenge' as const,
|
|
685
|
+
challenge,
|
|
686
|
+
clearanceEffect: rawExceeded.clearanceEffect === 'none' ? 'none' as const : 'bypass-rate-limit' as const,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
if (rawExceeded.type === '429') {
|
|
690
|
+
return { type: '429' as const };
|
|
691
|
+
}
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private normalizeChallenge(challenge?: IRouteSecurity['challenge']): IRouteSecurity['challenge'] | undefined {
|
|
696
|
+
if (challenge === null) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
if (!challenge || typeof challenge !== 'object') {
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
const providerId = typeof challenge.providerId === 'string' ? challenge.providerId.trim() : '';
|
|
703
|
+
const challengeType = typeof challenge.challengeType === 'string' ? challenge.challengeType.trim() : '';
|
|
704
|
+
if (!providerId || !challengeType) {
|
|
705
|
+
return undefined;
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
...structuredClone(challenge),
|
|
709
|
+
providerId,
|
|
710
|
+
challengeType,
|
|
655
711
|
};
|
|
656
712
|
}
|
|
657
713
|
|
|
@@ -782,6 +838,16 @@ export class RouteConfigManager {
|
|
|
782
838
|
let preparedRoute = route;
|
|
783
839
|
const http3Config = this.getHttp3Config?.();
|
|
784
840
|
|
|
841
|
+
if (routeNeedsHttpProtocol(preparedRoute)) {
|
|
842
|
+
preparedRoute = {
|
|
843
|
+
...preparedRoute,
|
|
844
|
+
match: {
|
|
845
|
+
...preparedRoute.match,
|
|
846
|
+
protocol: 'http',
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
785
851
|
if (http3Config?.enabled !== false) {
|
|
786
852
|
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
|
|
787
853
|
}
|
|
@@ -188,6 +188,10 @@ export class SourcePolicyCompiler {
|
|
|
188
188
|
if (bindingRateLimitError) {
|
|
189
189
|
return bindingRateLimitError;
|
|
190
190
|
}
|
|
191
|
+
const bindingChallengeError = this.validateChallengePayload(binding.challenge);
|
|
192
|
+
if (bindingChallengeError) {
|
|
193
|
+
return bindingChallengeError;
|
|
194
|
+
}
|
|
191
195
|
const bindingMessage = binding.onExceeded?.errorMessage;
|
|
192
196
|
if (typeof bindingMessage === 'string' && bindingMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
|
|
193
197
|
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
|
|
@@ -221,6 +225,10 @@ export class SourcePolicyCompiler {
|
|
|
221
225
|
if (pathRateLimitError) {
|
|
222
226
|
return pathRateLimitError;
|
|
223
227
|
}
|
|
228
|
+
const pathChallengeError = this.validateChallengePayload(pathPolicy.challenge);
|
|
229
|
+
if (pathChallengeError) {
|
|
230
|
+
return pathChallengeError;
|
|
231
|
+
}
|
|
224
232
|
const pathMessage = pathPolicy.onExceeded?.errorMessage;
|
|
225
233
|
if (typeof pathMessage === 'string' && pathMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
|
|
226
234
|
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
|
|
@@ -255,9 +263,12 @@ export class SourcePolicyCompiler {
|
|
|
255
263
|
}
|
|
256
264
|
|
|
257
265
|
private static validateRateLimitPayload(rateLimit: IRouteSecurity['rateLimit'] | undefined): string | undefined {
|
|
258
|
-
if (
|
|
266
|
+
if (rateLimit === null || rateLimit === undefined) {
|
|
259
267
|
return undefined;
|
|
260
268
|
}
|
|
269
|
+
if (typeof rateLimit !== 'object') {
|
|
270
|
+
return 'Source policy rate limit must be an object, null, or omitted';
|
|
271
|
+
}
|
|
261
272
|
const rawRateLimit = rateLimit as unknown as Record<string, unknown>;
|
|
262
273
|
for (const key of ['maxRequests', 'window'] as const) {
|
|
263
274
|
const value = rawRateLimit[key];
|
|
@@ -271,6 +282,53 @@ export class SourcePolicyCompiler {
|
|
|
271
282
|
) {
|
|
272
283
|
return `Source policy rate limit error message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
|
|
273
284
|
}
|
|
285
|
+
const rawOnExceeded = rawRateLimit.onExceeded;
|
|
286
|
+
if (rawOnExceeded !== undefined) {
|
|
287
|
+
if (!rawOnExceeded || typeof rawOnExceeded !== 'object') {
|
|
288
|
+
return 'Source policy rate limit onExceeded must be an object or omitted';
|
|
289
|
+
}
|
|
290
|
+
const onExceeded = rawOnExceeded as NonNullable<IRouteSecurity['rateLimit']>['onExceeded'];
|
|
291
|
+
if (!onExceeded || !['429', 'challenge'].includes(onExceeded.type)) {
|
|
292
|
+
return 'Source policy rate limit onExceeded.type must be 429 or challenge';
|
|
293
|
+
}
|
|
294
|
+
if (
|
|
295
|
+
onExceeded.clearanceEffect !== undefined
|
|
296
|
+
&& !['bypass-rate-limit', 'none'].includes(onExceeded.clearanceEffect)
|
|
297
|
+
) {
|
|
298
|
+
return 'Source policy rate limit onExceeded.clearanceEffect must be bypass-rate-limit or none';
|
|
299
|
+
}
|
|
300
|
+
if (onExceeded.type === 'challenge') {
|
|
301
|
+
if (!onExceeded.challenge) {
|
|
302
|
+
return 'Source policy rate limit challenge requires challenge config';
|
|
303
|
+
}
|
|
304
|
+
const challengeError = this.validateChallengePayload(onExceeded.challenge);
|
|
305
|
+
if (challengeError) {
|
|
306
|
+
return challengeError;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private static validateChallengePayload(challenge: IRouteSecurity['challenge'] | undefined): string | undefined {
|
|
314
|
+
if (challenge === null || challenge === undefined) {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
if (typeof challenge !== 'object') {
|
|
318
|
+
return 'Source policy challenge must be an object, null, or omitted';
|
|
319
|
+
}
|
|
320
|
+
if (typeof challenge.providerId !== 'string' || challenge.providerId.trim().length === 0) {
|
|
321
|
+
return 'Source policy challenge requires providerId';
|
|
322
|
+
}
|
|
323
|
+
if (typeof challenge.challengeType !== 'string' || challenge.challengeType.trim().length === 0) {
|
|
324
|
+
return 'Source policy challenge requires challengeType';
|
|
325
|
+
}
|
|
326
|
+
if (challenge.providerId.length > sourcePolicyLimits.maxIdLength) {
|
|
327
|
+
return `Source policy challenge providerId exceeds ${sourcePolicyLimits.maxIdLength} characters`;
|
|
328
|
+
}
|
|
329
|
+
if (challenge.challengeType.length > sourcePolicyLimits.maxIdLength) {
|
|
330
|
+
return `Source policy challenge challengeType exceeds ${sourcePolicyLimits.maxIdLength} characters`;
|
|
331
|
+
}
|
|
274
332
|
return undefined;
|
|
275
333
|
}
|
|
276
334
|
|
|
@@ -421,6 +479,19 @@ export class SourcePolicyCompiler {
|
|
|
421
479
|
)
|
|
422
480
|
: 0;
|
|
423
481
|
|
|
482
|
+
const security = this.buildBindingSecurity(
|
|
483
|
+
options.route.security,
|
|
484
|
+
options.profileSecurity,
|
|
485
|
+
options.binding,
|
|
486
|
+
options.pathPolicy,
|
|
487
|
+
);
|
|
488
|
+
const match: plugins.smartproxy.IRouteConfig['match'] = options.pathPattern
|
|
489
|
+
? { ...options.sourceMatch, path: options.pathPattern }
|
|
490
|
+
: { ...options.sourceMatch };
|
|
491
|
+
if (this.requiresHttpProtocol(security)) {
|
|
492
|
+
match.protocol = 'http';
|
|
493
|
+
}
|
|
494
|
+
|
|
424
495
|
return {
|
|
425
496
|
...options.route,
|
|
426
497
|
id: pathPolicyKey
|
|
@@ -429,16 +500,9 @@ export class SourcePolicyCompiler {
|
|
|
429
500
|
name: pathLabel
|
|
430
501
|
? `${options.route.name || routeKey}:source:${options.profileName}:path:${pathLabel}${pathPatternSuffix}`
|
|
431
502
|
: `${options.route.name || routeKey}:source:${options.profileName}`,
|
|
432
|
-
match
|
|
433
|
-
? { ...options.sourceMatch, path: options.pathPattern }
|
|
434
|
-
: { ...options.sourceMatch },
|
|
503
|
+
match,
|
|
435
504
|
priority: this.clampPriority(options.sourcePriority + pathPriority),
|
|
436
|
-
security
|
|
437
|
-
options.route.security,
|
|
438
|
-
options.profileSecurity,
|
|
439
|
-
options.binding,
|
|
440
|
-
options.pathPolicy,
|
|
441
|
-
),
|
|
505
|
+
security,
|
|
442
506
|
};
|
|
443
507
|
}
|
|
444
508
|
|
|
@@ -624,17 +688,27 @@ export class SourcePolicyCompiler {
|
|
|
624
688
|
private static forceIpRateLimit(
|
|
625
689
|
rateLimit: IRouteSecurity['rateLimit'] | undefined,
|
|
626
690
|
): IRouteSecurity['rateLimit'] | undefined {
|
|
627
|
-
if (
|
|
691
|
+
if (rateLimit === null) {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
if (!rateLimit || rateLimit.enabled === false) {
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
const { headerName: _headerName, ...rest } = structuredClone(rateLimit);
|
|
698
|
+
if (Number(rest.maxRequests) <= 0 || Number(rest.window) <= 0) {
|
|
628
699
|
return undefined;
|
|
629
700
|
}
|
|
630
|
-
const { headerName: _headerName, ...rest } = structuredClone(rateLimit as Record<string, any>);
|
|
631
701
|
return {
|
|
632
702
|
...rest,
|
|
703
|
+
enabled: true,
|
|
633
704
|
keyBy: 'ip',
|
|
634
705
|
} as IRouteSecurity['rateLimit'];
|
|
635
706
|
}
|
|
636
707
|
|
|
637
|
-
private static sanitizeSourcePolicySecurity(
|
|
708
|
+
private static sanitizeSourcePolicySecurity(
|
|
709
|
+
security: IRouteSecurity,
|
|
710
|
+
options: { preserveClears?: boolean } = {},
|
|
711
|
+
): IRouteSecurity {
|
|
638
712
|
const sanitized = structuredClone(security);
|
|
639
713
|
const maxConnections = this.normalizeMaxConnections(sanitized.maxConnections);
|
|
640
714
|
if (maxConnections === undefined) {
|
|
@@ -642,8 +716,19 @@ export class SourcePolicyCompiler {
|
|
|
642
716
|
} else {
|
|
643
717
|
sanitized.maxConnections = maxConnections;
|
|
644
718
|
}
|
|
645
|
-
if (sanitized.rateLimit) {
|
|
646
|
-
|
|
719
|
+
if (sanitized.rateLimit !== undefined && sanitized.rateLimit !== null) {
|
|
720
|
+
const rateLimit = this.forceIpRateLimit(sanitized.rateLimit);
|
|
721
|
+
if (rateLimit === undefined) {
|
|
722
|
+
delete sanitized.rateLimit;
|
|
723
|
+
} else {
|
|
724
|
+
sanitized.rateLimit = rateLimit;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (sanitized.rateLimit === null && !options.preserveClears) {
|
|
728
|
+
delete sanitized.rateLimit;
|
|
729
|
+
}
|
|
730
|
+
if (sanitized.challenge === null && !options.preserveClears) {
|
|
731
|
+
delete sanitized.challenge;
|
|
647
732
|
}
|
|
648
733
|
return sanitized;
|
|
649
734
|
}
|
|
@@ -676,12 +761,20 @@ export class SourcePolicyCompiler {
|
|
|
676
761
|
profileSecurity: IRouteSecurity,
|
|
677
762
|
binding: IRouteSourceBinding,
|
|
678
763
|
pathPolicy?: IRoutePathPolicyBinding,
|
|
679
|
-
):
|
|
764
|
+
): plugins.smartproxy.IRouteConfig['security'] {
|
|
680
765
|
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
|
|
681
|
-
|
|
766
|
+
delete baseSecurity.rateLimit;
|
|
767
|
+
delete baseSecurity.challenge;
|
|
768
|
+
const sourceSecurity = this.omitSourceMatchFields(profileSecurity, { preserveClears: true });
|
|
682
769
|
|
|
683
770
|
if (binding.rateLimit !== undefined) {
|
|
684
|
-
|
|
771
|
+
const rateLimit = this.forceIpRateLimit(binding.rateLimit);
|
|
772
|
+
if (rateLimit !== undefined) {
|
|
773
|
+
sourceSecurity.rateLimit = rateLimit;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (binding.challenge !== undefined) {
|
|
777
|
+
sourceSecurity.challenge = binding.challenge;
|
|
685
778
|
}
|
|
686
779
|
if (binding.maxConnections !== undefined) {
|
|
687
780
|
const maxConnections = this.normalizeMaxConnections(binding.maxConnections);
|
|
@@ -699,7 +792,13 @@ export class SourcePolicyCompiler {
|
|
|
699
792
|
}
|
|
700
793
|
|
|
701
794
|
if (pathPolicy?.rateLimit !== undefined) {
|
|
702
|
-
|
|
795
|
+
const rateLimit = this.forceIpRateLimit(pathPolicy.rateLimit);
|
|
796
|
+
if (rateLimit !== undefined) {
|
|
797
|
+
sourceSecurity.rateLimit = rateLimit;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (pathPolicy?.challenge !== undefined) {
|
|
801
|
+
sourceSecurity.challenge = pathPolicy.challenge;
|
|
703
802
|
}
|
|
704
803
|
if (pathPolicy?.maxConnections !== undefined) {
|
|
705
804
|
const maxConnections = this.normalizeMaxConnections(pathPolicy.maxConnections);
|
|
@@ -721,11 +820,30 @@ export class SourcePolicyCompiler {
|
|
|
721
820
|
...sourceSecurity,
|
|
722
821
|
});
|
|
723
822
|
|
|
724
|
-
|
|
823
|
+
if (this.isEmptySecurity(mergedSecurity)) {
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const { rateLimit, challenge, ...rest } = mergedSecurity;
|
|
828
|
+
return {
|
|
829
|
+
...rest,
|
|
830
|
+
...(rateLimit ? { rateLimit } : {}),
|
|
831
|
+
...(challenge ? { challenge } : {}),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private static requiresHttpProtocol(security: IRouteSecurity | undefined): boolean {
|
|
836
|
+
return Boolean(
|
|
837
|
+
security?.challenge
|
|
838
|
+
|| (security?.rateLimit && security.rateLimit !== null && security.rateLimit.onExceeded?.type === 'challenge'),
|
|
839
|
+
);
|
|
725
840
|
}
|
|
726
841
|
|
|
727
|
-
private static omitSourceMatchFields(
|
|
842
|
+
private static omitSourceMatchFields(
|
|
843
|
+
security: IRouteSecurity,
|
|
844
|
+
options: { preserveClears?: boolean } = {},
|
|
845
|
+
): IRouteSecurity {
|
|
728
846
|
const { ipAllowList: _ipAllowList, ...controls } = security;
|
|
729
|
-
return this.sanitizeSourcePolicySecurity(controls);
|
|
847
|
+
return this.sanitizeSourcePolicySecurity(controls, options);
|
|
730
848
|
}
|
|
731
849
|
}
|
package/ts/dns/manager.dns.ts
CHANGED
|
@@ -221,15 +221,24 @@ export class DnsManager {
|
|
|
221
221
|
value?: string,
|
|
222
222
|
): Promise<void> {
|
|
223
223
|
const records = await DnsRecordDoc.findByDomainId(domainId);
|
|
224
|
+
const failedDeletes: string[] = [];
|
|
224
225
|
for (const rec of records) {
|
|
225
226
|
if (
|
|
226
227
|
rec.name.toLowerCase() === name.toLowerCase()
|
|
227
228
|
&& rec.type === type
|
|
228
229
|
&& (value === undefined || rec.value === value)
|
|
229
230
|
) {
|
|
230
|
-
await this.deleteRecord(rec.id);
|
|
231
|
+
const deleteResult = await this.deleteRecord(rec.id);
|
|
232
|
+
if (!deleteResult.success) {
|
|
233
|
+
failedDeletes.push(deleteResult.message || `failed to delete DNS record ${rec.id}`);
|
|
234
|
+
}
|
|
231
235
|
}
|
|
232
236
|
}
|
|
237
|
+
if (failedDeletes.length > 0) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`DnsManager: failed to delete ${type} record(s) for ${name}: ${failedDeletes.join('; ')}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
233
242
|
}
|
|
234
243
|
|
|
235
244
|
/**
|
|
@@ -274,7 +283,7 @@ export class DnsManager {
|
|
|
274
283
|
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
|
275
284
|
}
|
|
276
285
|
// Create the challenge TXT record via the unified path
|
|
277
|
-
await self.createRecord({
|
|
286
|
+
const createResult = await self.createRecord({
|
|
278
287
|
domainId: domainDoc.id,
|
|
279
288
|
name: dnsChallenge.hostName,
|
|
280
289
|
type: 'TXT',
|
|
@@ -282,6 +291,12 @@ export class DnsManager {
|
|
|
282
291
|
ttl: 120,
|
|
283
292
|
createdBy: 'acme-dns01',
|
|
284
293
|
});
|
|
294
|
+
if (!createResult.success) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
createResult.message ||
|
|
297
|
+
`DnsManager: failed to create TXT challenge for ${dnsChallenge.hostName}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
285
300
|
},
|
|
286
301
|
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
|
287
302
|
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
|
|
@@ -49,6 +49,14 @@ function isEmailRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export function routeNeedsHttpProtocol(route: plugins.smartproxy.IRouteConfig): boolean {
|
|
53
|
+
const security = route.security;
|
|
54
|
+
return Boolean(
|
|
55
|
+
security?.challenge
|
|
56
|
+
|| security?.rateLimit?.onExceeded?.type === 'challenge',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
/**
|
|
53
61
|
* Determine if a route qualifies for HTTP/3 augmentation.
|
|
54
62
|
*/
|
|
@@ -103,7 +111,9 @@ export function augmentRouteWithHttp3(
|
|
|
103
111
|
match: {
|
|
104
112
|
...route.match,
|
|
105
113
|
transport: 'all' as const,
|
|
106
|
-
...(route
|
|
114
|
+
...(routeNeedsHttpProtocol(route)
|
|
115
|
+
? { protocol: 'http' as const }
|
|
116
|
+
: {}),
|
|
107
117
|
},
|
|
108
118
|
action: {
|
|
109
119
|
...route.action,
|