@push.rocks/smartproxy 27.19.0 → 27.21.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.
Files changed (33) hide show
  1. package/changelog.md +28 -0
  2. package/dist_rust/rustproxy_linux_amd64 +0 -0
  3. package/dist_rust/rustproxy_linux_amd64.tsrust-build.json +14 -0
  4. package/dist_rust/rustproxy_linux_arm64 +0 -0
  5. package/dist_rust/rustproxy_linux_arm64.tsrust-build.json +14 -0
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/core/models/route-context.d.ts +2 -0
  8. package/dist_ts/core/models/route-context.js +1 -1
  9. package/dist_ts/proxies/smart-proxy/datagram-handler-server.js +6 -4
  10. package/dist_ts/proxies/smart-proxy/models/metrics-types.d.ts +2 -0
  11. package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +8 -0
  12. package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
  13. package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +1 -0
  14. package/dist_ts/proxies/smart-proxy/rust-metrics-adapter.d.ts +5 -1
  15. package/dist_ts/proxies/smart-proxy/rust-metrics-adapter.js +37 -10
  16. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +2 -0
  17. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +5 -1
  18. package/dist_ts/proxies/smart-proxy/smart-proxy.js +5 -5
  19. package/dist_ts/proxies/smart-proxy/socket-handler-server.js +2 -1
  20. package/dist_ts/proxies/smart-proxy/utils/route-validator.js +28 -1
  21. package/package.json +6 -6
  22. package/readme.metrics.md +7 -1
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/core/models/route-context.ts +2 -0
  25. package/ts/proxies/smart-proxy/datagram-handler-server.ts +14 -3
  26. package/ts/proxies/smart-proxy/models/metrics-types.ts +2 -0
  27. package/ts/proxies/smart-proxy/models/route-types.ts +10 -0
  28. package/ts/proxies/smart-proxy/models/rust-types.ts +1 -0
  29. package/ts/proxies/smart-proxy/rust-metrics-adapter.ts +30 -8
  30. package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +5 -0
  31. package/ts/proxies/smart-proxy/smart-proxy.ts +4 -4
  32. package/ts/proxies/smart-proxy/socket-handler-server.ts +1 -0
  33. package/ts/proxies/smart-proxy/utils/route-validator.ts +28 -0
package/readme.metrics.md CHANGED
@@ -4,12 +4,18 @@
4
4
 
5
5
  Two-tier design separating the data plane from the observation plane:
6
6
 
7
- **Hot path (per-chunk, lock-free):** All recording in the proxy data plane touches only `AtomicU64` counters. No `Mutex` is ever acquired on the forwarding path. `CountingBody` batches flushes every 64KB to reduce DashMap shard contention.
7
+ **Byte recording (per-chunk):** Byte recording uses `AtomicU64` counters. `CountingBody` batches flushes every 64KB to reduce DashMap shard contention. Per-domain HTTP request recording uses a short tracker mutex; admitting a new domain also takes an admission mutex.
8
8
 
9
9
  **Cold path (1Hz sampling):** A background tokio task drains pending atomics into `ThroughputTracker` circular buffers (Mutex-guarded), producing per-second throughput history. Same task prunes orphaned entries and cleans up rate limiter state.
10
10
 
11
11
  **Read path (on-demand):** `snapshot()` reads all atomics and locks ThroughputTrackers to build a serializable `Metrics` struct. TypeScript polls this at 1s intervals via IPC.
12
12
 
13
+ Metrics polling allows one outstanding snapshot RPC. Slow polls coalesce; stopping drains the outstanding poll and clears the snapshot/history caches. Native shutdown awaits confirmed child and stdio closure through SmartRust.
14
+
15
+ Global HTTP domain rates retain at most 4,096 canonical domain keys, each at most 253 bytes. Idle trackers expire after their 60 completed one-second samples become empty. `requests.byDomain()` contains individually tracked domains. `requests.domainOverflow?.()` returns `{ perSecond, lastMinute }` for requests that could not receive an individual bucket. Overflow is separate from domain attribution; global request totals remain unchanged. Overflow requests are not retroactively assigned when a bucket later becomes available.
16
+
17
+ Active connection pages retain the smallest `limit + 1` matching IDs in a bounded heap, then clone metadata for those IDs. Work remains a scan of active connections, with O(page size) temporary storage rather than cloning/sorting the full registry. Connections closing during a scan may leave a short page; cursor order and filtering remain best effort under concurrent changes.
18
+
13
19
  ```
14
20
  Data Plane (lock-free) Background (1Hz) Read Path
15
21
  ───────────────────── ────────────────── ─────────
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartproxy',
6
- version: '27.19.0',
6
+ version: '27.21.0',
7
7
  description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
8
8
  }
@@ -1,4 +1,5 @@
1
1
  import * as plugins from '../../plugins.js';
2
+ import type { TIngressOrigin } from '../../proxies/smart-proxy/models/route-types.js';
2
3
 
3
4
  /**
4
5
  * Shared Route Context Interface
@@ -16,6 +17,7 @@ export interface IRouteContext {
16
17
  domain?: string; // The domain from SNI or Host header
17
18
  clientIp: string; // The client's IP address
18
19
  serverIp: string; // The server's IP address
20
+ ingressOrigin?: TIngressOrigin; // Trusted connection-origin classification
19
21
  vpn?: { // Authenticated VPN identity from trusted PROXY v2 metadata
20
22
  clientId: string;
21
23
  assignedIp: string;
@@ -13,8 +13,17 @@ interface IDatagramRelayMessage {
13
13
  routeKey: string;
14
14
  sourceIp: string;
15
15
  sourcePort: number;
16
+ clientIp?: string;
17
+ clientPort?: number;
16
18
  destPort: number;
17
19
  payloadBase64: string;
20
+ ingressOrigin?: 'direct' | 'trustedProxy' | 'smartVpn';
21
+ vpn?: {
22
+ clientId: string;
23
+ assignedIp: string;
24
+ transportType?: string;
25
+ remoteAddr?: string;
26
+ };
18
27
  }
19
28
 
20
29
  /**
@@ -179,8 +188,10 @@ export class DatagramHandlerServer {
179
188
  const context: IRouteContext = {
180
189
  port: msg.destPort,
181
190
  domain: undefined,
182
- clientIp: msg.sourceIp,
191
+ clientIp: msg.clientIp || msg.sourceIp,
183
192
  serverIp: '0.0.0.0',
193
+ ingressOrigin: msg.ingressOrigin,
194
+ vpn: msg.vpn,
184
195
  path: undefined,
185
196
  isTls: false,
186
197
  tlsVersion: undefined,
@@ -191,8 +202,8 @@ export class DatagramHandlerServer {
191
202
  };
192
203
 
193
204
  const info: IDatagramInfo = {
194
- sourceIp: msg.sourceIp,
195
- sourcePort: msg.sourcePort,
205
+ sourceIp: msg.clientIp || msg.sourceIp,
206
+ sourcePort: msg.clientPort ?? msg.sourcePort,
196
207
  destPort: msg.destPort,
197
208
  context,
198
209
  };
@@ -101,6 +101,8 @@ export interface IMetrics {
101
101
  perMinute(): number;
102
102
  total(): number;
103
103
  byDomain(): Map<string, IRequestRateMetrics>;
104
+ /** Requests beyond the 4096-domain/253-byte key budget, without individual attribution. */
105
+ domainOverflow?(): IRequestRateMetrics;
104
106
  };
105
107
 
106
108
  // Cumulative totals
@@ -24,6 +24,11 @@ export type TTlsMode = 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
24
24
  */
25
25
  export type TTransportProtocol = 'tcp' | 'udp' | 'all';
26
26
 
27
+ /**
28
+ * Trusted transport path established by SmartProxy for an accepted connection.
29
+ */
30
+ export type TIngressOrigin = 'direct' | 'trustedProxy' | 'smartVpn';
31
+
27
32
  /**
28
33
  * Inbound PROXY protocol handling for a listener.
29
34
  */
@@ -197,6 +202,11 @@ export interface IRouteSecurity {
197
202
  allowedAssignedIps?: string[];
198
203
  };
199
204
 
205
+ ingress?: {
206
+ /** Omit ingress entirely for legacy all-origin behavior. */
207
+ allowedOrigins: TIngressOrigin[];
208
+ };
209
+
200
210
  /** Browser-facing challenge policy. SmartProxy enforces this in the HTTP path. */
201
211
  challenge?: plugins.smartchallenge.IRouteChallengeConfig;
202
212
  }
@@ -178,6 +178,7 @@ export interface IRustMetricsSnapshot {
178
178
  httpRequestsPerSec: number;
179
179
  httpRequestsPerSecRecent: number;
180
180
  httpDomainRequests: Record<string, IRustHttpDomainRequestMetrics>;
181
+ httpDomainRequestsOverflow?: IRustHttpDomainRequestMetrics;
181
182
  activeUdpSessions: number;
182
183
  totalUdpSessions: number;
183
184
  totalDatagramsIn: number;
@@ -89,6 +89,9 @@ export class RustMetricsAdapter implements IMetrics {
89
89
  private historyCache: IRustMetricsHistory | null = null;
90
90
  private pollTimer: ReturnType<typeof setInterval> | null = null;
91
91
  private pollIntervalMs: number;
92
+ private pollPromise: Promise<void> | null = null;
93
+ private generation = 0;
94
+ private stopped = false;
92
95
 
93
96
  constructor(bridge: RustProxyBridge, pollIntervalMs = 1000) {
94
97
  this.bridge = bridge;
@@ -98,20 +101,29 @@ export class RustMetricsAdapter implements IMetrics {
98
101
  /**
99
102
  * Poll Rust for metrics once. Can be awaited to ensure cache is fresh.
100
103
  */
101
- public async poll(): Promise<void> {
102
- try {
103
- this.cache = await this.bridge.getMetrics();
104
- } catch {
105
- // Ignore poll errors (bridge may be shutting down)
106
- }
104
+ public poll(): Promise<void> {
105
+ if (this.stopped) return Promise.resolve();
106
+ if (this.pollPromise) return this.pollPromise;
107
+ const generation = this.generation;
108
+ this.pollPromise = (async () => {
109
+ try {
110
+ const metrics = await this.bridge.getMetrics();
111
+ if (!this.stopped && generation === this.generation) this.cache = metrics;
112
+ } catch {
113
+ // A failed poll leaves the last successful snapshot available until stop.
114
+ }
115
+ })().finally(() => { this.pollPromise = null; });
116
+ return this.pollPromise;
107
117
  }
108
118
 
109
119
  /**
110
120
  * Fetch histories only when an observer needs them; regular polling stays lightweight.
111
121
  */
112
122
  public async refreshHistory(seconds: number): Promise<IMetricsHistory> {
123
+ if (this.stopped) throw new Error('Metrics adapter is stopped');
124
+ const generation = this.generation;
113
125
  const history = await this.bridge.getMetricsHistory(seconds);
114
- this.historyCache = history;
126
+ if (!this.stopped && generation === this.generation) this.historyCache = history;
115
127
  return {
116
128
  throughputHistory: history.throughputHistory.map((point) => ({
117
129
  timestamp: point.timestampMs,
@@ -129,6 +141,7 @@ export class RustMetricsAdapter implements IMetrics {
129
141
 
130
142
  public startPolling(): void {
131
143
  if (this.pollTimer) return;
144
+ this.stopped = false;
132
145
  // Immediate first poll so cache is populated ASAP
133
146
  this.poll();
134
147
  this.pollTimer = setInterval(() => {
@@ -139,11 +152,16 @@ export class RustMetricsAdapter implements IMetrics {
139
152
  }
140
153
  }
141
154
 
142
- public stopPolling(): void {
155
+ public async stopPolling(): Promise<void> {
156
+ this.stopped = true;
157
+ this.generation++;
143
158
  if (this.pollTimer) {
144
159
  clearInterval(this.pollTimer);
145
160
  this.pollTimer = null;
146
161
  }
162
+ this.cache = null;
163
+ this.historyCache = null;
164
+ await this.pollPromise;
147
165
  }
148
166
 
149
167
  // --- IMetrics implementation ---
@@ -322,6 +340,10 @@ export class RustMetricsAdapter implements IMetrics {
322
340
  total: (): number => {
323
341
  return this.cache?.totalHttpRequests ?? this.cache?.totalConnections ?? 0;
324
342
  },
343
+ domainOverflow: (): IRequestRateMetrics => ({
344
+ perSecond: this.cache?.httpDomainRequestsOverflow?.requestsPerSecond ?? 0,
345
+ lastMinute: this.cache?.httpDomainRequestsOverflow?.requestsLastMinute ?? 0,
346
+ }),
325
347
  byDomain: (): Map<string, IRequestRateMetrics> => {
326
348
  const result = new Map<string, IRequestRateMetrics>();
327
349
  if (this.cache?.httpDomainRequests) {
@@ -141,6 +141,11 @@ export class RustProxyBridge extends plugins.EventEmitter {
141
141
  this.bridge.kill();
142
142
  }
143
143
 
144
+ /** Resolve only when the owned native child and its stdio handles have closed. */
145
+ public async terminate(): Promise<void> {
146
+ await this.bridge.terminate();
147
+ }
148
+
144
149
  /**
145
150
  * Whether the bridge is currently running.
146
151
  */
@@ -319,7 +319,7 @@ export class SmartProxy extends plugins.EventEmitter {
319
319
  }
320
320
 
321
321
  // Stop metrics polling
322
- this.metricsAdapter.stopPolling();
322
+ await this.metricsAdapter.stopPolling();
323
323
 
324
324
  // Remove exit listener before killing to avoid spurious error events
325
325
  this.bridge.removeAllListeners('exit');
@@ -330,7 +330,7 @@ export class SmartProxy extends plugins.EventEmitter {
330
330
  } catch {
331
331
  // Ignore if already stopped
332
332
  }
333
- this.bridge.kill();
333
+ await this.bridge.terminate();
334
334
 
335
335
  // Stop socket handler relay
336
336
  if (this.socketHandlerServer) {
@@ -582,7 +582,7 @@ export class SmartProxy extends plugins.EventEmitter {
582
582
  // --- Private helpers ---
583
583
 
584
584
  private async cleanupRuntimeResourcesAfterStartFailure(didSpawn: boolean): Promise<void> {
585
- this.metricsAdapter.stopPolling();
585
+ await this.metricsAdapter.stopPolling();
586
586
 
587
587
  if (this.nftablesManager) {
588
588
  await this.nftablesManager.cleanup().catch((err) => {
@@ -616,7 +616,7 @@ export class SmartProxy extends plugins.EventEmitter {
616
616
  if (didSpawn) {
617
617
  this.bridge.removeAllListeners('exit');
618
618
  await this.bridge.stopProxy().catch(() => undefined);
619
- this.bridge.kill();
619
+ await this.bridge.terminate();
620
620
  }
621
621
  }
622
622
 
@@ -169,6 +169,7 @@ export class SocketHandlerServer {
169
169
  domain: metadata.domain,
170
170
  clientIp: metadata.remoteIP || 'unknown',
171
171
  serverIp: '0.0.0.0',
172
+ ingressOrigin: metadata.ingressOrigin,
172
173
  vpn: metadata.vpn,
173
174
  path: metadata.path,
174
175
  isTls: metadata.isTLS || false,
@@ -269,6 +269,34 @@ export class RouteValidator {
269
269
  }
270
270
  }
271
271
 
272
+ if (route.security.ingress) {
273
+ const origins = route.security.ingress.allowedOrigins;
274
+ if (!Array.isArray(origins) || origins.length === 0) {
275
+ errors.push('security.ingress.allowedOrigins must be a non-empty array');
276
+ } else {
277
+ const supportedOrigins = new Set(['direct', 'trustedProxy', 'smartVpn']);
278
+ const uniqueOrigins = new Set(origins);
279
+ if (uniqueOrigins.size !== origins.length) {
280
+ errors.push('security.ingress.allowedOrigins must not contain duplicates');
281
+ }
282
+ for (const origin of origins) {
283
+ if (!supportedOrigins.has(origin)) {
284
+ errors.push(`Invalid security ingress origin: ${origin}`);
285
+ }
286
+ }
287
+
288
+ const inboundMode = route.match.inboundProxyProtocol?.mode;
289
+ const allowsDirect = origins.includes('direct');
290
+ const allowsTrustedProxy = origins.includes('trustedProxy') || origins.includes('smartVpn');
291
+ if (allowsDirect && inboundMode === 'required') {
292
+ errors.push('inboundProxyProtocol.required conflicts with the allowed direct ingress origin');
293
+ }
294
+ if (allowsTrustedProxy && inboundMode === 'reject') {
295
+ errors.push('inboundProxyProtocol.reject conflicts with trustedProxy or smartVpn ingress origins');
296
+ }
297
+ }
298
+ }
299
+
272
300
  // Validate connection limits
273
301
  if (route.security.maxConnections && route.security.maxConnections < 0) {
274
302
  errors.push('Max connections must be positive');