@push.rocks/smartproxy 19.6.15 → 19.6.17

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/readme.plan.md CHANGED
@@ -1,45 +1,281 @@
1
- # SmartProxy Connection Limiting Improvements Plan
1
+ # SmartProxy Implementation Plan
2
2
 
3
- Command to re-read CLAUDE.md: `cat /home/philkunz/.claude/CLAUDE.md`
3
+ ## Feature: Custom Certificate Provision Function
4
4
 
5
- ## Issues Identified
5
+ ### Summary
6
+ This plan implements the `certProvisionFunction` feature that allows users to provide their own certificate generation logic. The function can either return a custom certificate or delegate back to Let's Encrypt by returning 'http01'.
6
7
 
7
- 1. **HttpProxy Bypass**: Connections forwarded to HttpProxy for TLS termination only check global limits, not per-IP limits
8
- 2. **Missing Route-Level Connection Enforcement**: Routes can define `security.maxConnections` but it's never enforced
9
- 3. **Cleanup Queue Race Condition**: New connections can be added to cleanup queue while processing
10
- 4. **IP Tracking Memory Optimization**: IP entries remain in map even without active connections
8
+ ### Key Changes
9
+ 1. Add `certProvisionFunction` support to CertificateManager
10
+ 2. Modify `provisionAcmeCertificate()` to check custom function first
11
+ 3. Add certificate expiry parsing for custom certificates
12
+ 4. Support both initial provisioning and renewal
13
+ 5. Add fallback configuration option
11
14
 
12
- ## Implementation Steps
15
+ ### Overview
16
+ Implement the `certProvisionFunction` callback that's defined in the interface but currently not implemented. This will allow users to provide custom certificate generation logic while maintaining backward compatibility with the existing Let's Encrypt integration.
13
17
 
14
- ### 1. Fix HttpProxy Per-IP Validation ✓
15
- - [x] Pass IP information to HttpProxy when forwarding connections
16
- - [x] Add per-IP validation in HttpProxy connection handler
17
- - [x] Ensure connection tracking is consistent between SmartProxy and HttpProxy
18
+ ### Requirements
19
+ 1. The function should be called for any new certificate provisioning or renewal
20
+ 2. Must support returning custom certificates or falling back to Let's Encrypt
21
+ 3. Should integrate seamlessly with the existing certificate lifecycle
22
+ 4. Must maintain backward compatibility
18
23
 
19
- ### 2. Implement Route-Level Connection Limits ✓
20
- - [x] Add connection count tracking per route in ConnectionManager
21
- - [x] Update SharedSecurityManager.isAllowed() to check route-specific maxConnections
22
- - [x] Add route connection limit validation in route-connection-handler.ts
24
+ ### Implementation Steps
23
25
 
24
- ### 3. Fix Cleanup Queue Race Condition
25
- - [x] Implement proper queue snapshotting before processing
26
- - [x] Ensure new connections added during processing aren't missed
27
- - [x] Add proper synchronization for cleanup operations
26
+ #### 1. Update Certificate Manager to Support Custom Provision Function
27
+ **File**: `ts/proxies/smart-proxy/certificate-manager.ts`
28
28
 
29
- ### 4. Optimize IP Tracking Memory Usage
30
- - [x] Add periodic cleanup for IPs with no active connections
31
- - [x] Implement expiry for rate limit timestamps
32
- - [x] Add memory-efficient data structures for IP tracking
29
+ - [ ] Add `certProvisionFunction` property to CertificateManager class
30
+ - [ ] Pass the function from SmartProxy options during initialization
31
+ - [ ] Modify `provisionCertificate()` method to check for custom function first
33
32
 
34
- ### 5. Add Comprehensive Tests
35
- - [x] Test per-IP limits with HttpProxy forwarding
36
- - [x] Test route-level connection limits
37
- - [x] Test cleanup queue edge cases
38
- - [x] Test memory usage with many unique IPs
33
+ #### 2. Implement Custom Certificate Provisioning Logic
34
+ **Location**: Modify `provisionAcmeCertificate()` method
39
35
 
40
- ## Notes
36
+ ```typescript
37
+ private async provisionAcmeCertificate(
38
+ route: IRouteConfig,
39
+ domains: string[]
40
+ ): Promise<void> {
41
+ const primaryDomain = domains[0];
42
+ const routeName = route.name || primaryDomain;
43
+
44
+ // Check for custom provision function first
45
+ if (this.certProvisionFunction) {
46
+ try {
47
+ logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain });
48
+ const result = await this.certProvisionFunction(primaryDomain);
49
+
50
+ if (result === 'http01') {
51
+ logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`);
52
+ // Continue with existing ACME logic below
53
+ } else {
54
+ // Use custom certificate
55
+ const customCert = result as plugins.tsclass.network.ICert;
56
+
57
+ // Convert to internal certificate format
58
+ const certData: ICertificateData = {
59
+ cert: customCert.cert,
60
+ key: customCert.key,
61
+ ca: customCert.ca || '',
62
+ issueDate: new Date(),
63
+ expiryDate: this.extractExpiryDate(customCert.cert)
64
+ };
65
+
66
+ // Store and apply certificate
67
+ await this.certStore.saveCertificate(routeName, certData);
68
+ await this.applyCertificate(primaryDomain, certData);
69
+ this.updateCertStatus(routeName, 'valid', 'custom', certData);
70
+
71
+ logger.log('info', `Custom certificate applied for ${primaryDomain}`, {
72
+ domain: primaryDomain,
73
+ expiryDate: certData.expiryDate
74
+ });
75
+ return;
76
+ }
77
+ } catch (error) {
78
+ logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, {
79
+ domain: primaryDomain,
80
+ error: error.message
81
+ });
82
+ // Configuration option to control fallback behavior
83
+ if (this.smartProxy.settings.certProvisionFallbackToAcme !== false) {
84
+ logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`);
85
+ } else {
86
+ throw error;
87
+ }
88
+ }
89
+ }
90
+
91
+ // Existing Let's Encrypt logic continues here...
92
+ if (!this.smartAcme) {
93
+ throw new Error('SmartAcme not initialized...');
94
+ }
95
+ // ... rest of existing code
96
+ }
97
+ ```
41
98
 
42
- - All connection limiting is now consistent across SmartProxy and HttpProxy
43
- - Route-level limits provide additional granular control
44
- - Memory usage is optimized for high-traffic scenarios
45
- - Comprehensive test coverage ensures reliability
99
+ #### 3. Add Helper Method for Certificate Expiry Extraction
100
+ **New method**: `extractExpiryDate()`
101
+
102
+ - [ ] Parse PEM certificate to extract expiry date
103
+ - [ ] Use existing certificate parsing utilities
104
+ - [ ] Handle parse errors gracefully
105
+
106
+ ```typescript
107
+ private extractExpiryDate(certPem: string): Date {
108
+ try {
109
+ // Use forge or similar library to parse certificate
110
+ const cert = forge.pki.certificateFromPem(certPem);
111
+ return cert.validity.notAfter;
112
+ } catch (error) {
113
+ // Default to 90 days if parsing fails
114
+ return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
115
+ }
116
+ }
117
+ ```
118
+
119
+ #### 4. Update SmartProxy Initialization
120
+ **File**: `ts/proxies/smart-proxy/index.ts`
121
+
122
+ - [ ] Pass `certProvisionFunction` from options to CertificateManager
123
+ - [ ] Validate function if provided
124
+
125
+ #### 5. Add Type Safety and Validation
126
+ **Tasks**:
127
+ - [ ] Validate returned certificate has required fields (cert, key, ca)
128
+ - [ ] Check certificate validity dates
129
+ - [ ] Ensure certificate matches requested domain
130
+
131
+ #### 6. Update Certificate Renewal Logic
132
+ **Location**: `checkAndRenewCertificates()`
133
+
134
+ - [ ] Ensure renewal checks work for both ACME and custom certificates
135
+ - [ ] Custom certificates should go through the same `provisionAcmeCertificate()` path
136
+ - [ ] The existing renewal logic already calls `provisionCertificate()` which will use our modified flow
137
+
138
+ ```typescript
139
+ // No changes needed here - the existing renewal logic will automatically
140
+ // use the custom provision function when calling provisionCertificate()
141
+ private async checkAndRenewCertificates(): Promise<void> {
142
+ // Existing code already handles this correctly
143
+ for (const route of routes) {
144
+ if (this.shouldRenewCertificate(cert, renewThreshold)) {
145
+ // This will call provisionCertificate -> provisionAcmeCertificate
146
+ // which now includes our custom function check
147
+ await this.provisionCertificate(route);
148
+ }
149
+ }
150
+ }
151
+ ```
152
+
153
+ #### 7. Add Integration Tests
154
+ **File**: `test/test.certificate-provision.ts`
155
+
156
+ - [ ] Test custom certificate provision
157
+ - [ ] Test fallback to Let's Encrypt ('http01' return)
158
+ - [ ] Test error handling
159
+ - [ ] Test renewal with custom function
160
+
161
+ #### 8. Update Documentation
162
+ **Files**:
163
+ - [ ] Update interface documentation
164
+ - [ ] Add examples to README
165
+ - [ ] Document ICert structure requirements
166
+
167
+ ### API Design
168
+
169
+ ```typescript
170
+ // Example usage
171
+ const proxy = new SmartProxy({
172
+ certProvisionFunction: async (domain: string) => {
173
+ // Option 1: Return custom certificate
174
+ const customCert = await myCustomCA.generateCert(domain);
175
+ return {
176
+ cert: customCert.certificate,
177
+ key: customCert.privateKey,
178
+ ca: customCert.chain
179
+ };
180
+
181
+ // Option 2: Use Let's Encrypt for certain domains
182
+ if (domain.endsWith('.internal')) {
183
+ return customCert;
184
+ }
185
+ return 'http01'; // Fallback to Let's Encrypt
186
+ },
187
+ certProvisionFallbackToAcme: true, // Default: true
188
+ routes: [...]
189
+ });
190
+ ```
191
+
192
+ ### Configuration Options to Add
193
+
194
+ ```typescript
195
+ interface ISmartProxyOptions {
196
+ // Existing options...
197
+
198
+ // Custom certificate provision function
199
+ certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>;
200
+
201
+ // Whether to fallback to ACME if custom provision fails
202
+ certProvisionFallbackToAcme?: boolean; // Default: true
203
+ }
204
+ ```
205
+
206
+ ### Error Handling Strategy
207
+
208
+ 1. **Custom Function Errors**:
209
+ - Log detailed error with domain context
210
+ - Option A: Fallback to Let's Encrypt (safer)
211
+ - Option B: Fail certificate provisioning (stricter)
212
+ - Make this configurable via option?
213
+
214
+ 2. **Invalid Certificate Returns**:
215
+ - Validate certificate structure
216
+ - Check expiry dates
217
+ - Verify domain match
218
+
219
+ ### Testing Plan
220
+
221
+ 1. **Unit Tests**:
222
+ - Mock certProvisionFunction returns
223
+ - Test validation logic
224
+ - Test error scenarios
225
+
226
+ 2. **Integration Tests**:
227
+ - Real certificate generation
228
+ - Renewal cycle testing
229
+ - Mixed custom/Let's Encrypt scenarios
230
+
231
+ ### Backward Compatibility
232
+
233
+ - If no `certProvisionFunction` provided, behavior unchanged
234
+ - Existing routes with 'auto' certificates continue using Let's Encrypt
235
+ - No breaking changes to existing API
236
+
237
+ ### Future Enhancements
238
+
239
+ 1. **Per-Route Custom Functions**:
240
+ - Allow different provision functions per route
241
+ - Override global function at route level
242
+
243
+ 2. **Certificate Events**:
244
+ - Emit events for custom cert provisioning
245
+ - Allow monitoring/logging hooks
246
+
247
+ 3. **Async Certificate Updates**:
248
+ - Support updating certificates outside renewal cycle
249
+ - Hot-reload certificates without restart
250
+
251
+ ### Implementation Notes
252
+
253
+ 1. **Certificate Status Tracking**:
254
+ - The `updateCertStatus()` method needs to support a new type: 'custom'
255
+ - Current types are 'acme' and 'static'
256
+ - This helps distinguish custom certificates in monitoring/logs
257
+
258
+ 2. **Certificate Store Integration**:
259
+ - Custom certificates are stored the same way as ACME certificates
260
+ - They participate in the same renewal cycle
261
+ - The store handles persistence across restarts
262
+
263
+ 3. **Existing Methods to Reuse**:
264
+ - `applyCertificate()` - Already handles applying certs to routes
265
+ - `isCertificateValid()` - Can validate custom certificates
266
+ - `certStore.saveCertificate()` - Handles storage
267
+
268
+ ### Implementation Priority
269
+
270
+ 1. Core functionality (steps 1-3)
271
+ 2. Type safety and validation (step 5)
272
+ 3. Renewal support (step 6)
273
+ 4. Tests (step 7)
274
+ 5. Documentation (step 8)
275
+
276
+ ### Estimated Effort
277
+
278
+ - Core implementation: 4-6 hours
279
+ - Testing: 2-3 hours
280
+ - Documentation: 1 hour
281
+ - Total: ~8-10 hours
@@ -269,6 +269,7 @@ export class LogDeduplicator {
269
269
 
270
270
  private flushIPRejections(aggregated: IAggregatedEvent): void {
271
271
  const byIP = new Map<string, { count: number; reasons: Set<string> }>();
272
+ const allReasons = new Map<string, number>();
272
273
 
273
274
  for (const [ip, event] of aggregated.events) {
274
275
  if (!byIP.has(ip)) {
@@ -278,9 +279,17 @@ export class LogDeduplicator {
278
279
  ipData.count += event.count;
279
280
  if (event.data?.reason) {
280
281
  ipData.reasons.add(event.data.reason);
282
+ // Track overall reason counts
283
+ allReasons.set(event.data.reason, (allReasons.get(event.data.reason) || 0) + event.count);
281
284
  }
282
285
  }
283
286
 
287
+ // Create reason summary
288
+ const reasonSummary = Array.from(allReasons.entries())
289
+ .sort((a, b) => b[1] - a[1])
290
+ .map(([reason, count]) => `${reason}: ${count}`)
291
+ .join(', ');
292
+
284
293
  // Log top offenders
285
294
  const topOffenders = Array.from(byIP.entries())
286
295
  .sort((a, b) => b[1].count - a[1].count)
@@ -291,7 +300,7 @@ export class LogDeduplicator {
291
300
  const totalRejections = Array.from(byIP.values()).reduce((sum, data) => sum + data.count, 0);
292
301
 
293
302
  const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
294
- logger.log('warn', `[SUMMARY] Rejected ${totalRejections} connections from ${byIP.size} IPs in ${Math.round(duration/1000)}s`, {
303
+ logger.log('warn', `[SUMMARY] Rejected ${totalRejections} connections from ${byIP.size} IPs in ${Math.round(duration/1000)}s (${reasonSummary})`, {
295
304
  topOffenders,
296
305
  component: 'ip-dedup'
297
306
  });
@@ -12,7 +12,7 @@ export interface ICertStatus {
12
12
  status: 'valid' | 'pending' | 'expired' | 'error';
13
13
  expiryDate?: Date;
14
14
  issueDate?: Date;
15
- source: 'static' | 'acme';
15
+ source: 'static' | 'acme' | 'custom';
16
16
  error?: string;
17
17
  }
18
18
 
@@ -22,6 +22,7 @@ export interface ICertificateData {
22
22
  ca?: string;
23
23
  expiryDate: Date;
24
24
  issueDate: Date;
25
+ source?: 'static' | 'acme' | 'custom';
25
26
  }
26
27
 
27
28
  export class SmartCertManager {
@@ -50,6 +51,12 @@ export class SmartCertManager {
50
51
  // ACME state manager reference
51
52
  private acmeStateManager: AcmeStateManager | null = null;
52
53
 
54
+ // Custom certificate provision function
55
+ private certProvisionFunction?: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>;
56
+
57
+ // Whether to fallback to ACME if custom provision fails
58
+ private certProvisionFallbackToAcme: boolean = true;
59
+
53
60
  constructor(
54
61
  private routes: IRouteConfig[],
55
62
  private certDir: string = './certs',
@@ -89,6 +96,20 @@ export class SmartCertManager {
89
96
  this.globalAcmeDefaults = defaults;
90
97
  }
91
98
 
99
+ /**
100
+ * Set custom certificate provision function
101
+ */
102
+ public setCertProvisionFunction(fn: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>): void {
103
+ this.certProvisionFunction = fn;
104
+ }
105
+
106
+ /**
107
+ * Set whether to fallback to ACME if custom provision fails
108
+ */
109
+ public setCertProvisionFallbackToAcme(fallback: boolean): void {
110
+ this.certProvisionFallbackToAcme = fallback;
111
+ }
112
+
92
113
  /**
93
114
  * Set callback for updating routes (used for challenge routes)
94
115
  */
@@ -212,15 +233,6 @@ export class SmartCertManager {
212
233
  route: IRouteConfig,
213
234
  domains: string[]
214
235
  ): Promise<void> {
215
- if (!this.smartAcme) {
216
- throw new Error(
217
- 'SmartAcme not initialized. This usually means no ACME email was provided. ' +
218
- 'Please ensure you have configured ACME with an email address either:\n' +
219
- '1. In the top-level "acme" configuration\n' +
220
- '2. In the route\'s "tls.acme" configuration'
221
- );
222
- }
223
-
224
236
  const primaryDomain = domains[0];
225
237
  const routeName = route.name || primaryDomain;
226
238
 
@@ -229,10 +241,68 @@ export class SmartCertManager {
229
241
  if (existingCert && this.isCertificateValid(existingCert)) {
230
242
  logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
231
243
  await this.applyCertificate(primaryDomain, existingCert);
232
- this.updateCertStatus(routeName, 'valid', 'acme', existingCert);
244
+ this.updateCertStatus(routeName, 'valid', existingCert.source || 'acme', existingCert);
233
245
  return;
234
246
  }
235
247
 
248
+ // Check for custom provision function first
249
+ if (this.certProvisionFunction) {
250
+ try {
251
+ logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
252
+ const result = await this.certProvisionFunction(primaryDomain);
253
+
254
+ if (result === 'http01') {
255
+ logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
256
+ // Continue with existing ACME logic below
257
+ } else {
258
+ // Use custom certificate
259
+ const customCert = result as plugins.tsclass.network.ICert;
260
+
261
+ // Convert to internal certificate format
262
+ const certData: ICertificateData = {
263
+ cert: customCert.publicKey,
264
+ key: customCert.privateKey,
265
+ ca: '',
266
+ issueDate: new Date(),
267
+ expiryDate: this.extractExpiryDate(customCert.publicKey),
268
+ source: 'custom'
269
+ };
270
+
271
+ // Store and apply certificate
272
+ await this.certStore.saveCertificate(routeName, certData);
273
+ await this.applyCertificate(primaryDomain, certData);
274
+ this.updateCertStatus(routeName, 'valid', 'custom', certData);
275
+
276
+ logger.log('info', `Custom certificate applied for ${primaryDomain}`, {
277
+ domain: primaryDomain,
278
+ expiryDate: certData.expiryDate,
279
+ component: 'certificate-manager'
280
+ });
281
+ return;
282
+ }
283
+ } catch (error) {
284
+ logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, {
285
+ domain: primaryDomain,
286
+ error: error.message,
287
+ component: 'certificate-manager'
288
+ });
289
+ // Check if we should fallback to ACME
290
+ if (!this.certProvisionFallbackToAcme) {
291
+ throw error;
292
+ }
293
+ logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
294
+ }
295
+ }
296
+
297
+ if (!this.smartAcme) {
298
+ throw new Error(
299
+ 'SmartAcme not initialized. This usually means no ACME email was provided. ' +
300
+ 'Please ensure you have configured ACME with an email address either:\n' +
301
+ '1. In the top-level "acme" configuration\n' +
302
+ '2. In the route\'s "tls.acme" configuration'
303
+ );
304
+ }
305
+
236
306
  // Apply renewal threshold from global defaults or route config
237
307
  const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
238
308
  this.globalAcmeDefaults?.renewThresholdDays ||
@@ -280,7 +350,8 @@ export class SmartCertManager {
280
350
  key: cert.privateKey,
281
351
  ca: cert.publicKey, // Use same as cert for now
282
352
  expiryDate: new Date(cert.validUntil),
283
- issueDate: new Date(cert.created)
353
+ issueDate: new Date(cert.created),
354
+ source: 'acme'
284
355
  };
285
356
 
286
357
  await this.certStore.saveCertificate(routeName, certData);
@@ -328,7 +399,8 @@ export class SmartCertManager {
328
399
  cert,
329
400
  key,
330
401
  expiryDate: certInfo.validTo,
331
- issueDate: certInfo.validFrom
402
+ issueDate: certInfo.validFrom,
403
+ source: 'static'
332
404
  };
333
405
 
334
406
  // Save to store for consistency
@@ -399,6 +471,19 @@ export class SmartCertManager {
399
471
  return cert.expiryDate > expiryThreshold;
400
472
  }
401
473
 
474
+ /**
475
+ * Extract expiry date from a PEM certificate
476
+ */
477
+ private extractExpiryDate(_certPem: string): Date {
478
+ // For now, we'll default to 90 days for custom certificates
479
+ // In production, you might want to use a proper X.509 parser
480
+ // or require the custom cert provider to include expiry info
481
+ logger.log('info', 'Using default 90-day expiry for custom certificate', {
482
+ component: 'certificate-manager'
483
+ });
484
+ return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
485
+ }
486
+
402
487
 
403
488
  /**
404
489
  * Add challenge route to SmartProxy
@@ -135,6 +135,12 @@ export interface ISmartProxyOptions {
135
135
  * or a static certificate object for immediate provisioning.
136
136
  */
137
137
  certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>;
138
+
139
+ /**
140
+ * Whether to fallback to ACME if custom certificate provision fails.
141
+ * Default: true
142
+ */
143
+ certProvisionFallbackToAcme?: boolean;
138
144
  }
139
145
 
140
146
  /**
@@ -243,6 +243,16 @@ export class SmartProxy extends plugins.EventEmitter {
243
243
  certManager.setGlobalAcmeDefaults(this.settings.acme);
244
244
  }
245
245
 
246
+ // Pass down the custom certificate provision function if available
247
+ if (this.settings.certProvisionFunction) {
248
+ certManager.setCertProvisionFunction(this.settings.certProvisionFunction);
249
+ }
250
+
251
+ // Pass down the fallback to ACME setting
252
+ if (this.settings.certProvisionFallbackToAcme !== undefined) {
253
+ certManager.setCertProvisionFallbackToAcme(this.settings.certProvisionFallbackToAcme);
254
+ }
255
+
246
256
  await certManager.initialize();
247
257
  return certManager;
248
258
  }