fix(certificates): Update static-route certificate metadata for ACME challenges
This commit is contained in:
		| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "expiryDate": "2025-08-16T18:25:31.732Z", |   "expiryDate": "2025-08-17T12:04:34.427Z", | ||||||
|   "issueDate": "2025-05-18T18:25:31.732Z", |   "issueDate": "2025-05-19T12:04:34.427Z", | ||||||
|   "savedAt": "2025-05-18T18:25:31.734Z" |   "savedAt": "2025-05-19T12:04:34.429Z" | ||||||
| } | } | ||||||
| @@ -1,5 +1,10 @@ | |||||||
| # Changelog | # Changelog | ||||||
|  |  | ||||||
|  | ## 2025-05-19 - 19.3.1 - fix(certificates) | ||||||
|  | Update static-route certificate metadata for ACME challenges | ||||||
|  |  | ||||||
|  | - Updated expiryDate and issueDate in certs/static-route/meta.json to reflect new certificate issuance information | ||||||
|  |  | ||||||
| ## 2025-05-19 - 19.3.0 - feat(smartproxy) | ## 2025-05-19 - 19.3.0 - feat(smartproxy) | ||||||
| Update dependencies and enhance ACME certificate provisioning with wildcard support | Update dependencies and enhance ACME certificate provisioning with wildcard support | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										129
									
								
								test/test.acme-http-challenge.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								test/test.acme-http-challenge.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | |||||||
|  | import { tap, expect } from '@git.zone/tstest/tapbundle'; | ||||||
|  | import * as plugins from '../ts/plugins.js'; | ||||||
|  | import { SmartProxy } from '../ts/index.js'; | ||||||
|  |  | ||||||
|  | tap.test('should handle HTTP requests on port 80 for ACME challenges', async (tools) => { | ||||||
|  |   tools.timeout(10000); | ||||||
|  |    | ||||||
|  |   // Track HTTP requests that are handled | ||||||
|  |   const handledRequests: any[] = []; | ||||||
|  |    | ||||||
|  |   const settings = { | ||||||
|  |     routes: [ | ||||||
|  |       { | ||||||
|  |         name: 'acme-test-route', | ||||||
|  |         match: { | ||||||
|  |           ports: [18080],  // Use high port to avoid permission issues | ||||||
|  |           path: '/.well-known/acme-challenge/*' | ||||||
|  |         }, | ||||||
|  |         action: { | ||||||
|  |           type: 'static' as const, | ||||||
|  |           handler: async (context) => { | ||||||
|  |             handledRequests.push({ | ||||||
|  |               path: context.path, | ||||||
|  |               method: context.method, | ||||||
|  |               headers: context.headers | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             // Simulate ACME challenge response | ||||||
|  |             const token = context.path?.split('/').pop() || ''; | ||||||
|  |             return { | ||||||
|  |               status: 200, | ||||||
|  |               headers: { 'Content-Type': 'text/plain' }, | ||||||
|  |               body: `challenge-response-for-${token}` | ||||||
|  |             }; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   const proxy = new SmartProxy(settings); | ||||||
|  |    | ||||||
|  |   // Mock NFTables manager | ||||||
|  |   (proxy as any).nftablesManager = { | ||||||
|  |     ensureNFTablesSetup: async () => {}, | ||||||
|  |     stop: async () => {} | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   await proxy.start(); | ||||||
|  |    | ||||||
|  |   // Make an HTTP request to the challenge endpoint | ||||||
|  |   const response = await fetch('http://localhost:18080/.well-known/acme-challenge/test-token', { | ||||||
|  |     method: 'GET' | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   // Verify response | ||||||
|  |   expect(response.status).toEqual(200); | ||||||
|  |   const body = await response.text(); | ||||||
|  |   expect(body).toEqual('challenge-response-for-test-token'); | ||||||
|  |    | ||||||
|  |   // Verify request was handled | ||||||
|  |   expect(handledRequests.length).toEqual(1); | ||||||
|  |   expect(handledRequests[0].path).toEqual('/.well-known/acme-challenge/test-token'); | ||||||
|  |   expect(handledRequests[0].method).toEqual('GET'); | ||||||
|  |    | ||||||
|  |   await proxy.stop(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should parse HTTP headers correctly', async (tools) => { | ||||||
|  |   tools.timeout(10000); | ||||||
|  |    | ||||||
|  |   const capturedContext: any = {}; | ||||||
|  |    | ||||||
|  |   const settings = { | ||||||
|  |     routes: [ | ||||||
|  |       { | ||||||
|  |         name: 'header-test-route', | ||||||
|  |         match: { | ||||||
|  |           ports: [18081] | ||||||
|  |         }, | ||||||
|  |         action: { | ||||||
|  |           type: 'static' as const, | ||||||
|  |           handler: async (context) => { | ||||||
|  |             Object.assign(capturedContext, context); | ||||||
|  |             return { | ||||||
|  |               status: 200, | ||||||
|  |               headers: { 'Content-Type': 'application/json' }, | ||||||
|  |               body: JSON.stringify({ | ||||||
|  |                 received: context.headers | ||||||
|  |               }) | ||||||
|  |             }; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   const proxy = new SmartProxy(settings); | ||||||
|  |    | ||||||
|  |   // Mock NFTables manager | ||||||
|  |   (proxy as any).nftablesManager = { | ||||||
|  |     ensureNFTablesSetup: async () => {}, | ||||||
|  |     stop: async () => {} | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   await proxy.start(); | ||||||
|  |    | ||||||
|  |   // Make request with custom headers | ||||||
|  |   const response = await fetch('http://localhost:18081/test', { | ||||||
|  |     method: 'POST', | ||||||
|  |     headers: { | ||||||
|  |       'X-Custom-Header': 'test-value', | ||||||
|  |       'User-Agent': 'test-agent' | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   expect(response.status).toEqual(200); | ||||||
|  |   const body = await response.json(); | ||||||
|  |    | ||||||
|  |   // Verify headers were parsed correctly | ||||||
|  |   expect(capturedContext.headers['x-custom-header']).toEqual('test-value'); | ||||||
|  |   expect(capturedContext.headers['user-agent']).toEqual('test-agent'); | ||||||
|  |   expect(capturedContext.method).toEqual('POST'); | ||||||
|  |   expect(capturedContext.path).toEqual('/test'); | ||||||
|  |    | ||||||
|  |   await proxy.stop(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.start(); | ||||||
							
								
								
									
										139
									
								
								test/test.acme-route-creation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								test/test.acme-route-creation.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | |||||||
|  | import { tap, expect } from '@git.zone/tstest/tapbundle'; | ||||||
|  | import { SmartProxy } from '../ts/index.js'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Test that verifies ACME challenge routes are properly created | ||||||
|  |  */ | ||||||
|  | tap.test('should create ACME challenge route with high ports', async (tools) => { | ||||||
|  |   tools.timeout(5000); | ||||||
|  |    | ||||||
|  |   const capturedRoutes: any[] = []; | ||||||
|  |    | ||||||
|  |   const settings = { | ||||||
|  |     routes: [ | ||||||
|  |       { | ||||||
|  |         name: 'secure-route', | ||||||
|  |         match: { | ||||||
|  |           ports: [18443],  // High port to avoid permission issues | ||||||
|  |           domains: 'test.local' | ||||||
|  |         }, | ||||||
|  |         action: { | ||||||
|  |           type: 'forward' as const, | ||||||
|  |           target: { host: 'localhost', port: 8080 }, | ||||||
|  |           tls: { | ||||||
|  |             mode: 'terminate' as const, | ||||||
|  |             certificate: 'auto' | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     acme: { | ||||||
|  |       email: 'test@test.local', | ||||||
|  |       port: 18080  // High port for ACME challenges | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   const proxy = new SmartProxy(settings); | ||||||
|  |    | ||||||
|  |   // Capture route updates | ||||||
|  |   const originalUpdateRoutes = (proxy as any).updateRoutesInternal.bind(proxy); | ||||||
|  |   (proxy as any).updateRoutesInternal = async function(routes: any[]) { | ||||||
|  |     capturedRoutes.push([...routes]); | ||||||
|  |     return originalUpdateRoutes(routes); | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   await proxy.start(); | ||||||
|  |    | ||||||
|  |   // Check that ACME challenge route was added | ||||||
|  |   const finalRoutes = capturedRoutes[capturedRoutes.length - 1]; | ||||||
|  |   const challengeRoute = finalRoutes.find((r: any) => r.name === 'acme-challenge'); | ||||||
|  |    | ||||||
|  |   expect(challengeRoute).toBeDefined(); | ||||||
|  |   expect(challengeRoute.match.path).toEqual('/.well-known/acme-challenge/*'); | ||||||
|  |   expect(challengeRoute.match.ports).toEqual(18080); | ||||||
|  |   expect(challengeRoute.action.type).toEqual('static'); | ||||||
|  |   expect(challengeRoute.priority).toEqual(1000); | ||||||
|  |    | ||||||
|  |   await proxy.stop(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should handle HTTP request parsing correctly', async (tools) => { | ||||||
|  |   tools.timeout(5000); | ||||||
|  |    | ||||||
|  |   let handlerCalled = false; | ||||||
|  |   let receivedContext: any; | ||||||
|  |    | ||||||
|  |   const settings = { | ||||||
|  |     routes: [ | ||||||
|  |       { | ||||||
|  |         name: 'test-static', | ||||||
|  |         match: { | ||||||
|  |           ports: [18090], | ||||||
|  |           path: '/test/*' | ||||||
|  |         }, | ||||||
|  |         action: { | ||||||
|  |           type: 'static' as const, | ||||||
|  |           handler: async (context) => { | ||||||
|  |             handlerCalled = true; | ||||||
|  |             receivedContext = context; | ||||||
|  |             return { | ||||||
|  |               status: 200, | ||||||
|  |               headers: { 'Content-Type': 'text/plain' }, | ||||||
|  |               body: 'OK' | ||||||
|  |             }; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   const proxy = new SmartProxy(settings); | ||||||
|  |    | ||||||
|  |   // Mock NFTables manager | ||||||
|  |   (proxy as any).nftablesManager = { | ||||||
|  |     ensureNFTablesSetup: async () => {}, | ||||||
|  |     stop: async () => {} | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   await proxy.start(); | ||||||
|  |    | ||||||
|  |   // Create a simple HTTP request | ||||||
|  |   const client = new plugins.net.Socket(); | ||||||
|  |    | ||||||
|  |   await new Promise<void>((resolve, reject) => { | ||||||
|  |     client.connect(18090, 'localhost', () => { | ||||||
|  |       // Send HTTP request | ||||||
|  |       const request = [ | ||||||
|  |         'GET /test/example HTTP/1.1', | ||||||
|  |         'Host: localhost:18090', | ||||||
|  |         'User-Agent: test-client', | ||||||
|  |         '', | ||||||
|  |         '' | ||||||
|  |       ].join('\r\n'); | ||||||
|  |        | ||||||
|  |       client.write(request); | ||||||
|  |        | ||||||
|  |       // Wait for response | ||||||
|  |       client.on('data', (data) => { | ||||||
|  |         const response = data.toString(); | ||||||
|  |         expect(response).toContain('HTTP/1.1 200'); | ||||||
|  |         expect(response).toContain('OK'); | ||||||
|  |         client.end(); | ||||||
|  |         resolve(); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     client.on('error', reject); | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   // Verify handler was called | ||||||
|  |   expect(handlerCalled).toBeTrue(); | ||||||
|  |   expect(receivedContext).toBeDefined(); | ||||||
|  |   expect(receivedContext.path).toEqual('/test/example'); | ||||||
|  |   expect(receivedContext.method).toEqual('GET'); | ||||||
|  |   expect(receivedContext.headers.host).toEqual('localhost:18090'); | ||||||
|  |    | ||||||
|  |   await proxy.stop(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.start(); | ||||||
							
								
								
									
										116
									
								
								test/test.acme-simple.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								test/test.acme-simple.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | |||||||
|  | import { tap, expect } from '@git.zone/tstest/tapbundle'; | ||||||
|  | import * as net from 'net'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Simple test to verify HTTP parsing works for ACME challenges | ||||||
|  |  */ | ||||||
|  | tap.test('should parse HTTP requests correctly', async (tools) => { | ||||||
|  |   tools.timeout(15000); | ||||||
|  |    | ||||||
|  |   let receivedRequest = ''; | ||||||
|  |    | ||||||
|  |   // Create a simple HTTP server to test the parsing | ||||||
|  |   const server = net.createServer((socket) => { | ||||||
|  |     socket.on('data', (data) => { | ||||||
|  |       receivedRequest = data.toString(); | ||||||
|  |        | ||||||
|  |       // Send response | ||||||
|  |       const response = [ | ||||||
|  |         'HTTP/1.1 200 OK', | ||||||
|  |         'Content-Type: text/plain', | ||||||
|  |         'Content-Length: 2', | ||||||
|  |         '', | ||||||
|  |         'OK' | ||||||
|  |       ].join('\r\n'); | ||||||
|  |        | ||||||
|  |       socket.write(response); | ||||||
|  |       socket.end(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   await new Promise<void>((resolve) => { | ||||||
|  |     server.listen(18091, () => { | ||||||
|  |       console.log('Test server listening on port 18091'); | ||||||
|  |       resolve(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   // Connect and send request | ||||||
|  |   const client = net.connect(18091, 'localhost'); | ||||||
|  |    | ||||||
|  |   await new Promise<void>((resolve, reject) => { | ||||||
|  |     client.on('connect', () => { | ||||||
|  |       const request = [ | ||||||
|  |         'GET /.well-known/acme-challenge/test-token HTTP/1.1', | ||||||
|  |         'Host: localhost:18091', | ||||||
|  |         'User-Agent: test-client', | ||||||
|  |         '', | ||||||
|  |         '' | ||||||
|  |       ].join('\r\n'); | ||||||
|  |        | ||||||
|  |       client.write(request); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     client.on('data', (data) => { | ||||||
|  |       const response = data.toString(); | ||||||
|  |       expect(response).toContain('200 OK'); | ||||||
|  |       client.end(); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     client.on('end', () => { | ||||||
|  |       resolve(); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     client.on('error', reject); | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   // Verify we received the request | ||||||
|  |   expect(receivedRequest).toContain('GET /.well-known/acme-challenge/test-token'); | ||||||
|  |   expect(receivedRequest).toContain('Host: localhost:18091'); | ||||||
|  |    | ||||||
|  |   server.close(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Test to verify ACME route configuration | ||||||
|  |  */ | ||||||
|  | tap.test('should configure ACME challenge route', async () => { | ||||||
|  |   // Simple test to verify the route configuration structure | ||||||
|  |   const challengeRoute = { | ||||||
|  |     name: 'acme-challenge', | ||||||
|  |     priority: 1000, | ||||||
|  |     match: { | ||||||
|  |       ports: 80, | ||||||
|  |       path: '/.well-known/acme-challenge/*' | ||||||
|  |     }, | ||||||
|  |     action: { | ||||||
|  |       type: 'static', | ||||||
|  |       handler: async (context: any) => { | ||||||
|  |         const token = context.path?.split('/').pop() || ''; | ||||||
|  |         return { | ||||||
|  |           status: 200, | ||||||
|  |           headers: { 'Content-Type': 'text/plain' }, | ||||||
|  |           body: `challenge-response-${token}` | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   expect(challengeRoute.name).toEqual('acme-challenge'); | ||||||
|  |   expect(challengeRoute.match.path).toEqual('/.well-known/acme-challenge/*'); | ||||||
|  |   expect(challengeRoute.match.ports).toEqual(80); | ||||||
|  |   expect(challengeRoute.priority).toEqual(1000); | ||||||
|  |    | ||||||
|  |   // Test the handler | ||||||
|  |   const context = { | ||||||
|  |     path: '/.well-known/acme-challenge/test-token', | ||||||
|  |     method: 'GET', | ||||||
|  |     headers: {} | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   const response = await challengeRoute.action.handler(context); | ||||||
|  |   expect(response.status).toEqual(200); | ||||||
|  |   expect(response.body).toEqual('challenge-response-test-token'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.start(); | ||||||
							
								
								
									
										104
									
								
								test/test.simple-acme-mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								test/test.simple-acme-mock.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | |||||||
|  | import { tap, expect } from '@git.zone/tstest/tapbundle'; | ||||||
|  | import { SmartProxy } from '../ts/index.js'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Simple test to check that ACME challenge routes are created | ||||||
|  |  */ | ||||||
|  | tap.test('should create ACME challenge route', async (tools) => { | ||||||
|  |   tools.timeout(5000); | ||||||
|  |    | ||||||
|  |   const mockRouteUpdates: any[] = []; | ||||||
|  |    | ||||||
|  |   const settings = { | ||||||
|  |     routes: [ | ||||||
|  |       { | ||||||
|  |         name: 'secure-route', | ||||||
|  |         match: { | ||||||
|  |           ports: [443], | ||||||
|  |           domains: 'test.example.com' | ||||||
|  |         }, | ||||||
|  |         action: { | ||||||
|  |           type: 'forward' as const, | ||||||
|  |           target: { host: 'localhost', port: 8080 }, | ||||||
|  |           tls: { | ||||||
|  |             mode: 'terminate' as const, | ||||||
|  |             certificate: 'auto', | ||||||
|  |             acme: { | ||||||
|  |               email: 'test@test.local'  // Use non-example.com domain | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   const proxy = new SmartProxy(settings); | ||||||
|  |    | ||||||
|  |   // Mock certificate manager | ||||||
|  |   let updateRoutesCallback: any; | ||||||
|  |    | ||||||
|  |   (proxy as any).createCertificateManager = async function(routes: any[], certDir: string, acmeOptions: any) { | ||||||
|  |     const mockCertManager = { | ||||||
|  |       setUpdateRoutesCallback: function(callback: any) { | ||||||
|  |         updateRoutesCallback = callback; | ||||||
|  |       }, | ||||||
|  |       setNetworkProxy: function() {}, | ||||||
|  |       setGlobalAcmeDefaults: function() {}, | ||||||
|  |       setAcmeStateManager: function() {}, | ||||||
|  |       initialize: async function() { | ||||||
|  |         // Simulate adding ACME challenge route | ||||||
|  |         if (updateRoutesCallback) { | ||||||
|  |           const challengeRoute = { | ||||||
|  |             name: 'acme-challenge', | ||||||
|  |             priority: 1000, | ||||||
|  |             match: { | ||||||
|  |               ports: 80, | ||||||
|  |               path: '/.well-known/acme-challenge/*' | ||||||
|  |             }, | ||||||
|  |             action: { | ||||||
|  |               type: 'static', | ||||||
|  |               handler: async (context: any) => { | ||||||
|  |                 const token = context.path?.split('/').pop() || ''; | ||||||
|  |                 return { | ||||||
|  |                   status: 200, | ||||||
|  |                   headers: { 'Content-Type': 'text/plain' }, | ||||||
|  |                   body: `mock-challenge-response-${token}` | ||||||
|  |                 }; | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }; | ||||||
|  |            | ||||||
|  |           const updatedRoutes = [...routes, challengeRoute]; | ||||||
|  |           mockRouteUpdates.push(updatedRoutes); | ||||||
|  |           await updateRoutesCallback(updatedRoutes); | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       getAcmeOptions: () => acmeOptions, | ||||||
|  |       getState: () => ({ challengeRouteActive: false }), | ||||||
|  |       stop: async () => {} | ||||||
|  |     }; | ||||||
|  |     return mockCertManager; | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   // Mock NFTables | ||||||
|  |   (proxy as any).nftablesManager = { | ||||||
|  |     ensureNFTablesSetup: async () => {}, | ||||||
|  |     stop: async () => {} | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   await proxy.start(); | ||||||
|  |    | ||||||
|  |   // Verify that routes were updated with challenge route | ||||||
|  |   expect(mockRouteUpdates.length).toBeGreaterThan(0); | ||||||
|  |    | ||||||
|  |   const lastUpdate = mockRouteUpdates[mockRouteUpdates.length - 1]; | ||||||
|  |   const challengeRoute = lastUpdate.find((r: any) => r.name === 'acme-challenge'); | ||||||
|  |    | ||||||
|  |   expect(challengeRoute).toBeDefined(); | ||||||
|  |   expect(challengeRoute.match.path).toEqual('/.well-known/acme-challenge/*'); | ||||||
|  |   expect(challengeRoute.match.ports).toEqual(80); | ||||||
|  |    | ||||||
|  |   await proxy.stop(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.start(); | ||||||
| @@ -3,6 +3,6 @@ | |||||||
|  */ |  */ | ||||||
| export const commitinfo = { | export const commitinfo = { | ||||||
|   name: '@push.rocks/smartproxy', |   name: '@push.rocks/smartproxy', | ||||||
|   version: '19.3.0', |   version: '19.3.1', | ||||||
|   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.' |   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.' | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user