@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "14.2.3",
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.14.1",
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.13.0",
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, and security policies
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, and backend forwarding |
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 and returns `429`; dcrouter does not fall through to later bindings.
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: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
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
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '14.2.3',
6
+ version: '14.3.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -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 < 0 || !Number.isFinite(window) || window < 0) {
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: rateLimit.enabled !== false,
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 (!rateLimit || typeof rateLimit !== 'object') {
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: options.pathPattern
433
- ? { ...options.sourceMatch, path: options.pathPattern }
434
- : { ...options.sourceMatch },
503
+ match,
435
504
  priority: this.clampPriority(options.sourcePriority + pathPriority),
436
- security: this.buildBindingSecurity(
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 (!rateLimit) {
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(security: IRouteSecurity): IRouteSecurity {
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
- sanitized.rateLimit = this.forceIpRateLimit(sanitized.rateLimit);
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
- ): IRouteSecurity | undefined {
764
+ ): plugins.smartproxy.IRouteConfig['security'] {
680
765
  const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
681
- const sourceSecurity = this.omitSourceMatchFields(profileSecurity);
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
- sourceSecurity.rateLimit = this.forceIpRateLimit(binding.rateLimit);
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
- sourceSecurity.rateLimit = this.forceIpRateLimit(pathPolicy.rateLimit);
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
- return this.isEmptySecurity(mergedSecurity) ? undefined : mergedSecurity;
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(security: IRouteSecurity): IRouteSecurity {
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
  }
@@ -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.security?.challenge ? { protocol: 'http' as const } : {}),
114
+ ...(routeNeedsHttpProtocol(route)
115
+ ? { protocol: 'http' as const }
116
+ : {}),
107
117
  },
108
118
  action: {
109
119
  ...route.action,
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '14.2.3',
6
+ version: '14.3.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }