@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.
- package/changelog.md +43 -0
- package/dist_rust/rustproxy_linux_amd64 +0 -0
- package/dist_rust/rustproxy_linux_amd64.tsrust-build.json +5 -5
- package/dist_rust/rustproxy_linux_arm64 +0 -0
- package/dist_rust/rustproxy_linux_arm64.tsrust-build.json +5 -5
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/index.d.ts +1 -1
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +15 -3
- package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +12 -6
- package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
- package/dist_ts/proxies/smart-proxy/smart-proxy.d.ts +14 -0
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +48 -8
- package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +2 -0
- package/dist_ts/proxies/smart-proxy/utils/route-validator.js +123 -3
- package/package.json +6 -6
- package/readme.md +49 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -1
- package/ts/proxies/smart-proxy/models/interfaces.ts +15 -3
- package/ts/proxies/smart-proxy/models/route-types.ts +14 -6
- package/ts/proxies/smart-proxy/smart-proxy.ts +53 -9
- package/ts/proxies/smart-proxy/utils/route-validator.ts +134 -2
- package/readme.hints.md +0 -561
- package/readme.metrics.md +0 -490
- package/readme.plan.md +0 -0
|
@@ -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
|
-
|
|
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<
|
|
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
|
}
|