@push.rocks/smartproxy 27.21.0 → 28.0.0

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.
@@ -25,6 +25,10 @@ import type { IRustCertificateStatus, IRustChallengeOptions, IRustProxyOptions,
25
25
 
26
26
  type TChallengeProvider = plugins.smartchallenge.IChallengeProvider;
27
27
 
28
+ /** What one certProvisionFunction call amounted to, in the order each sweep logs the tally. */
29
+ const certProvisionOutcomes = ['loaded', 'http01', 'deferred', 'failed', 'stopped', 'unrecognized'] as const;
30
+ type TCertProvisionOutcome = (typeof certProvisionOutcomes)[number];
31
+
28
32
  /**
29
33
  * SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
30
34
  *
@@ -489,6 +493,24 @@ export class SmartProxy extends plugins.EventEmitter {
489
493
  return this.bridge.getCertificateStatus(routeName);
490
494
  }
491
495
 
496
+ /**
497
+ * Lift the provisioning failure cooldown of one domain, so the next sweep
498
+ * (`updateRoutes()` or the renewal check) calls `certProvisionFunction` for it
499
+ * again instead of waiting out `certProvisionFailureCooldownMs`. Use it when the
500
+ * cause of a failure was resolved outside SmartProxy, for example after a
501
+ * certificate for the domain was issued out of band.
502
+ *
503
+ * @param domain The provisioning domain exactly as `certProvisionFunction`
504
+ * receives it and `certificate-failed` reports it.
505
+ * @returns true if the domain was cooling down, false if it was not (never
506
+ * failed, or its cooldown had already elapsed).
507
+ */
508
+ public invalidateCertProvisionCooldown(domain: string): boolean {
509
+ const wasCoolingDown = this.isInFailureCooldown(domain, Date.now());
510
+ this.certFailureAt.delete(domain);
511
+ return wasCoolingDown;
512
+ }
513
+
492
514
  /**
493
515
  * Get the metrics interface.
494
516
  */
@@ -558,7 +580,7 @@ export class SmartProxy extends plugins.EventEmitter {
558
580
  for (const route of this.settings.routes || []) {
559
581
  if (!route.match.domains) continue;
560
582
  if (
561
- route.action.type !== 'forward' ||
583
+ (route.action.type !== 'forward' && route.action.type !== 'respond') ||
562
584
  !route.action.tls ||
563
585
  route.action.tls.mode === 'passthrough' ||
564
586
  route.action.tls.certificate !== 'auto'
@@ -985,6 +1007,7 @@ export class SmartProxy extends plugins.EventEmitter {
985
1007
  * For routes with certificate: 'auto', call certProvisionFunction if set.
986
1008
  * If the callback returns a cert object, load it into Rust.
987
1009
  * If it returns 'http01', let Rust handle ACME.
1010
+ * If it returns 'defer', leave the domain for the next sweep.
988
1011
  */
989
1012
  private async provisionCertificatesViaCallback(skipDomains: Set<string> = new Set()): Promise<void> {
990
1013
  const provisionFn = this.settings.certProvisionFunction;
@@ -1045,16 +1068,28 @@ export class SmartProxy extends plugins.EventEmitter {
1045
1068
  const concurrency = this.settings.certProvisionConcurrency ?? 4;
1046
1069
  const semaphore = new ConcurrencySemaphore(concurrency);
1047
1070
 
1048
- const promises = tasks.map(async ({ domain, route }) => {
1071
+ const promises = tasks.map(async ({ domain, route }): Promise<TCertProvisionOutcome> => {
1049
1072
  await semaphore.acquire();
1050
1073
  try {
1051
- await this.provisionSingleDomain(domain, route, provisionFn);
1074
+ return await this.provisionSingleDomain(domain, route, provisionFn);
1052
1075
  } finally {
1053
1076
  semaphore.release();
1054
1077
  }
1055
1078
  });
1056
1079
 
1057
- await Promise.allSettled(promises);
1080
+ // provisionSingleDomain settles provisioning errors itself; it rejects only
1081
+ // when a certificate-failed listener throws, after the failure was recorded.
1082
+ const settled = await Promise.allSettled(promises);
1083
+ const tally = new Map<TCertProvisionOutcome, number>();
1084
+ for (const result of settled) {
1085
+ const outcome = result.status === 'fulfilled' ? result.value : 'failed';
1086
+ tally.set(outcome, (tally.get(outcome) ?? 0) + 1);
1087
+ }
1088
+ const summary = certProvisionOutcomes
1089
+ .filter((outcome) => tally.has(outcome))
1090
+ .map((outcome) => `${tally.get(outcome)} ${outcome}`)
1091
+ .join(', ');
1092
+ logger.log('info', `Cert provisioning sweep finished: ${summary}`, { component: 'smart-proxy' });
1058
1093
  }
1059
1094
 
1060
1095
  /**
@@ -1065,8 +1100,8 @@ export class SmartProxy extends plugins.EventEmitter {
1065
1100
  domain: string,
1066
1101
  route: IRouteConfig,
1067
1102
  provisionFn: (domain: string, eventComms: ICertProvisionEventComms) => Promise<TSmartProxyCertProvisionObject>,
1068
- ): Promise<void> {
1069
- if (this.stopping) return;
1103
+ ): Promise<TCertProvisionOutcome> {
1104
+ if (this.stopping) return 'stopped';
1070
1105
 
1071
1106
  let expiryDate: string | undefined;
1072
1107
  let source = 'certProvisionFunction';
@@ -1088,7 +1123,13 @@ export class SmartProxy extends plugins.EventEmitter {
1088
1123
  `Certificate provisioning timed out for ${domain} after ${timeoutMs}ms`,
1089
1124
  );
1090
1125
 
1091
- if (this.stopping) return;
1126
+ if (this.stopping) return 'stopped';
1127
+
1128
+ // A deferral is not a failure: no cooldown, no event, no ACME fallback.
1129
+ if (result === 'defer') {
1130
+ logger.log('info', `Certificate provisioning deferred for ${domain}; the next sweep retries it`, { component: 'smart-proxy' });
1131
+ return 'deferred';
1132
+ }
1092
1133
 
1093
1134
  if (result === 'http01') {
1094
1135
  if (route.name) {
@@ -1100,11 +1141,11 @@ export class SmartProxy extends plugins.EventEmitter {
1100
1141
  'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' });
1101
1142
  }
1102
1143
  }
1103
- return;
1144
+ return 'http01';
1104
1145
  }
1105
1146
 
1106
1147
  if (result && typeof result === 'object') {
1107
- if (this.stopping) return;
1148
+ if (this.stopping) return 'stopped';
1108
1149
 
1109
1150
  const certObj = result as ISmartProxyCertProvisionCertificate;
1110
1151
  await this.bridge.loadCertificate(
@@ -1135,7 +1176,9 @@ export class SmartProxy extends plugins.EventEmitter {
1135
1176
  expiryDate: expiryDate || (certObj.validUntil ? new Date(certObj.validUntil).toISOString() : undefined),
1136
1177
  source,
1137
1178
  } satisfies ICertificateIssuedEvent);
1179
+ return 'loaded';
1138
1180
  }
1181
+ return 'unrecognized';
1139
1182
  } catch (err: any) {
1140
1183
  logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
1141
1184
 
@@ -1161,6 +1204,7 @@ export class SmartProxy extends plugins.EventEmitter {
1161
1204
  : ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' });
1162
1205
  }
1163
1206
  }
1207
+ return 'failed';
1164
1208
  }
1165
1209
  }
1166
1210
 
@@ -6,7 +6,7 @@ import type { IInboundProxyProtocolPolicy, IRouteConfig, IRouteMatch, IRouteActi
6
6
  */
7
7
  export class RouteValidator {
8
8
  private static readonly VALID_TLS_MODES = ['terminate', 'passthrough', 'terminate-and-reencrypt'];
9
- private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler'];
9
+ private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler', 'respond'];
10
10
  private static readonly VALID_PROTOCOLS = ['tcp', 'http', 'https', 'ws', 'wss', 'udp', 'quic', 'http3'];
11
11
  private static readonly MAX_PORTS = 100;
12
12
  private static readonly MAX_DOMAINS = 1000;
@@ -149,6 +149,14 @@ export class RouteValidator {
149
149
  }
150
150
  }
151
151
 
152
+ // Validate fixed response
153
+ if (route.action.type === 'respond') {
154
+ errors.push(...getRespondActionErrors(route.action));
155
+ if (route.match) {
156
+ errors.push(...getRespondMatchErrors(route.match));
157
+ }
158
+ }
159
+
152
160
  // Validate forward target
153
161
  if (route.action.type === 'forward') {
154
162
  if (!route.action.targets || route.action.targets.length === 0) {
@@ -719,6 +727,120 @@ export function validateRouteMatch(match: IRouteMatch): { valid: boolean; errors
719
727
  };
720
728
  }
721
729
 
730
+ /** Largest fixed-response body a `respond` route may carry, in UTF-8 bytes. */
731
+ export const MAX_FIXED_RESPONSE_BODY_BYTES = 65536;
732
+
733
+ /** Hop-by-hop or computed headers a fixed response must not set. */
734
+ const FIXED_RESPONSE_FORBIDDEN_HEADERS = [
735
+ 'connection',
736
+ 'keep-alive',
737
+ 'transfer-encoding',
738
+ 'upgrade',
739
+ 'te',
740
+ 'trailer',
741
+ 'proxy-connection',
742
+ 'content-length',
743
+ ];
744
+
745
+ const HTTP_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
746
+ const HTTP_HEADER_VALUE_PATTERN = /^[\t\x20-\x7E]*$/;
747
+
748
+ /**
749
+ * Returns the action-level errors of a `respond` route: the fixed response itself,
750
+ * and the forwarding, handler, UDP and TLS options a fixed response cannot use.
751
+ */
752
+ function getRespondActionErrors(action: IRouteAction): string[] {
753
+ const errors: string[] = [];
754
+
755
+ if (action.targets !== undefined) {
756
+ errors.push('respond action must not define targets');
757
+ }
758
+ if (action.socketHandler !== undefined || action.datagramHandler !== undefined) {
759
+ errors.push('respond action must not define a socketHandler or datagramHandler');
760
+ }
761
+ if (action.forwardingEngine !== undefined || action.nftables !== undefined) {
762
+ errors.push('respond action must not define forwardingEngine or nftables options');
763
+ }
764
+ if (action.udp !== undefined) {
765
+ errors.push('respond action does not support UDP or HTTP/3 (action.udp)');
766
+ }
767
+ if (action.tls && action.tls.mode !== 'terminate') {
768
+ errors.push(`respond action supports only TLS mode "terminate", got "${action.tls.mode}"`);
769
+ }
770
+
771
+ const response = action.response;
772
+ if (!response || typeof response !== 'object') {
773
+ errors.push('respond action requires a response object');
774
+ return errors;
775
+ }
776
+
777
+ if (!Number.isInteger(response.status) || response.status < 200 || response.status > 599) {
778
+ errors.push(`response.status must be an integer from 200 to 599, got ${response.status}`);
779
+ }
780
+
781
+ const seenHeaders = new Set<string>();
782
+ let hasContentType = false;
783
+ if (response.headers !== undefined) {
784
+ if (!response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
785
+ errors.push('response.headers must be an object of header names to string values');
786
+ } else {
787
+ for (const [name, value] of Object.entries(response.headers)) {
788
+ const lowerName = name.toLowerCase();
789
+ if (!HTTP_TOKEN_PATTERN.test(name)) {
790
+ errors.push(`response.headers has an invalid header name: ${name}`);
791
+ continue;
792
+ }
793
+ if (FIXED_RESPONSE_FORBIDDEN_HEADERS.includes(lowerName)) {
794
+ errors.push(`response.headers must not set ${lowerName}`);
795
+ }
796
+ if (seenHeaders.has(lowerName)) {
797
+ errors.push(`response.headers sets ${lowerName} more than once`);
798
+ }
799
+ seenHeaders.add(lowerName);
800
+ if (typeof value !== 'string' || !HTTP_HEADER_VALUE_PATTERN.test(value)) {
801
+ errors.push(`response.headers.${name} must be a string of visible ASCII characters`);
802
+ }
803
+ if (lowerName === 'content-type') {
804
+ hasContentType = true;
805
+ }
806
+ }
807
+ }
808
+ }
809
+
810
+ if (response.body !== undefined) {
811
+ if (typeof response.body !== 'string') {
812
+ errors.push('response.body must be a string');
813
+ } else if (response.body.length > 0) {
814
+ const bodyBytes = new TextEncoder().encode(response.body).length;
815
+ if (bodyBytes > MAX_FIXED_RESPONSE_BODY_BYTES) {
816
+ errors.push(`response.body is ${bodyBytes} bytes, the maximum is ${MAX_FIXED_RESPONSE_BODY_BYTES}`);
817
+ }
818
+ if (response.status === 204 || response.status === 304) {
819
+ errors.push(`response.status ${response.status} must not carry a body`);
820
+ }
821
+ if (!hasContentType) {
822
+ errors.push('response.body requires an explicit content-type header');
823
+ }
824
+ }
825
+ }
826
+
827
+ return errors;
828
+ }
829
+
830
+ /**
831
+ * Returns the match-level errors of a `respond` route: a fixed response is HTTP over TCP only.
832
+ */
833
+ function getRespondMatchErrors(match: IRouteMatch): string[] {
834
+ const errors: string[] = [];
835
+ if (match.transport === 'udp' || match.transport === 'all') {
836
+ errors.push(`respond routes support only TCP transport, got "${match.transport}"`);
837
+ }
838
+ if (match.protocol !== undefined && match.protocol !== 'http') {
839
+ errors.push(`respond routes support only protocol "http", got "${match.protocol}"`);
840
+ }
841
+ return errors;
842
+ }
843
+
722
844
  /**
723
845
  * Validates a route action configuration
724
846
  * @param action Route action configuration to validate
@@ -729,10 +851,14 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err
729
851
 
730
852
  if (!action.type) {
731
853
  errors.push('Action type is required');
732
- } else if (!['forward', 'socket-handler'].includes(action.type)) {
854
+ } else if (!['forward', 'socket-handler', 'respond'].includes(action.type)) {
733
855
  errors.push(`Invalid action type: ${action.type}`);
734
856
  }
735
857
 
858
+ if (action.type === 'respond') {
859
+ errors.push(...getRespondActionErrors(action));
860
+ }
861
+
736
862
  if (action.type === 'forward') {
737
863
  if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) {
738
864
  errors.push('Targets array is required for forward action');
@@ -836,6 +962,10 @@ export function validateRouteConfig(route: IRouteConfig): { valid: boolean; erro
836
962
  }
837
963
  }
838
964
 
965
+ if (route.match && route.action?.type === 'respond') {
966
+ errors.push(...getRespondMatchErrors(route.match).map(err => `Match: ${err}`));
967
+ }
968
+
839
969
  return {
840
970
  valid: errors.length === 0,
841
971
  errors
@@ -889,6 +1019,8 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType:
889
1019
  case 'socket-handler':
890
1020
  return (!!route.action.socketHandler && typeof route.action.socketHandler === 'function') ||
891
1021
  (!!route.action.datagramHandler && typeof route.action.datagramHandler === 'function');
1022
+ case 'respond':
1023
+ return !!route.action.response && typeof route.action.response.status === 'number';
892
1024
  default:
893
1025
  return false;
894
1026
  }