From 3a62a7857da88bb7e025b1445b592f2929754a2a Mon Sep 17 00:00:00 2001 From: Ian Langworth Date: Wed, 28 Jan 2026 16:48:21 -0800 Subject: [PATCH 1/4] Fix critical memory leaks and add graceful shutdown - Fix UDP socket leak: reuse discovery socket instead of creating new ones - Add graceful shutdown on SIGTERM/SIGINT for Docker compatibility - Cache snapshot.png in memory to prevent repeated file I/O - Prevent duplicate event listener registration in debug mode - Add error handlers for HTTP server and discovery socket - Implement shutdown() method to properly clean up resources Co-authored-by: Claude Code --- main.js | 19 +++++++++- src/onvif-server.js | 92 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/main.js b/main.js index edae20e..9ecf62e 100644 --- a/main.js +++ b/main.js @@ -86,6 +86,7 @@ if (args) { } let proxies = {}; + let servers = []; for (let onvifConfig of config.onvif) { let server = onvifServer.createServer(onvifConfig, logger); @@ -95,12 +96,13 @@ if (args) { server.startDiscovery(); if (args.debug) server.enableDebugOutput(); + servers.push(server); logger.info(' Started!'); logger.info(''); if (!proxies[onvifConfig.target.hostname]) proxies[onvifConfig.target.hostname] = {} - + if (onvifConfig.ports.rtsp && onvifConfig.target.ports.rtsp) proxies[onvifConfig.target.hostname][onvifConfig.ports.rtsp] = onvifConfig.target.ports.rtsp; if (onvifConfig.ports.snapshot && onvifConfig.target.ports.snapshot) @@ -120,6 +122,21 @@ if (args) { } } + // Graceful shutdown on SIGTERM/SIGINT (important for Docker) + const gracefulShutdown = async (signal) => { + logger.info(`Received ${signal}, shutting down gracefully...`); + + // Shutdown all ONVIF servers + const shutdownPromises = servers.map(server => server.shutdown()); + await Promise.all(shutdownPromises); + + logger.info('All servers shut down successfully'); + process.exit(0); + }; + + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); + } else { logger.error('Please specifiy a config filename!'); return -1; diff --git a/src/onvif-server.js b/src/onvif-server.js index 0965cee..184b455 100644 --- a/src/onvif-server.js +++ b/src/onvif-server.js @@ -30,6 +30,8 @@ class OnvifServer { constructor(config, logger) { this.config = config; this.logger = logger; + this.snapshotCache = null; + this.debugListenersAdded = false; if (!this.config.hostname) this.config.hostname = getIpAddressFromMac(this.config.mac); @@ -335,9 +337,19 @@ class OnvifServer { listen(request, response) { let action = url.parse(request.url, true).pathname; if (action == '/snapshot.png') { - let image = fs.readFileSync('./resources/snapshot.png'); + // Cache snapshot image to avoid repeated file I/O + if (!this.snapshotCache) { + try { + this.snapshotCache = fs.readFileSync('./resources/snapshot.png'); + } catch (err) { + this.logger.error('Failed to read snapshot.png: ' + err.message); + response.writeHead(500, {'Content-Type': 'text/plain'}); + response.end('Internal Server Error\n'); + return; + } + } response.writeHead(200, {'Content-Type': 'image/png' }); - response.end(image, 'binary'); + response.end(this.snapshotCache, 'binary'); } else { response.writeHead(404, {'Content-Type': 'text/plain'}); response.write('404 Not Found\n'); @@ -347,17 +359,23 @@ class OnvifServer { startServer() { this.server = http.createServer(this.listen); + + // Add error handler to prevent crashes + this.server.on('error', (err) => { + this.logger.error('HTTP Server error: ' + err.message); + }); + this.server.listen(this.config.ports.server, this.config.hostname); this.deviceService = soap.listen(this.server, { - path: '/onvif/device_service', + path: '/onvif/device_service', services: this.onvif, xml: fs.readFileSync('./wsdl/device_service.wsdl', 'utf8'), forceSoap12Headers: true }); this.mediaService = soap.listen(this.server, { - path: '/onvif/media_service', + path: '/onvif/media_service', services: this.onvif, xml: fs.readFileSync('./wsdl/media_service.wsdl', 'utf8'), forceSoap12Headers: true @@ -365,10 +383,16 @@ class OnvifServer { } enableDebugOutput() { + // Prevent adding duplicate event listeners + if (this.debugListenersAdded) { + return; + } + this.debugListenersAdded = true; + this.deviceService.on('request', (request, methodName) => { this.logger.debug('DeviceService: ' + methodName); }); - + this.mediaService.on('request', (request, methodName) => { this.logger.debug('MediaService: ' + methodName); }); @@ -377,7 +401,12 @@ class OnvifServer { startDiscovery() { this.discoveryMessageNo = 0; this.discoverySocket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); - + + // Add error handler to prevent crashes + this.discoverySocket.on('error', (err) => { + this.logger.error('Discovery socket error: ' + err.message); + }); + this.discoverySocket.on('message', (message, remote) => { xml2js.parseString(message.toString(), { tagNameProcessors: [xml2js['processors'].stripPrefix] }, (err, result) => { let probeUuid = result['Envelope']['Header'][0]['MessageID'][0]; @@ -428,7 +457,12 @@ class OnvifServer { this.discoveryMessageNo++; let responseBuffer = Buffer.from(response); - return dgram.createSocket('udp4').send(responseBuffer, 0, responseBuffer.length, remote.port, remote.address); + // Reuse existing socket instead of creating new ones (memory leak fix) + this.discoverySocket.send(responseBuffer, 0, responseBuffer.length, remote.port, remote.address, (err) => { + if (err) { + this.logger.error('Discovery response send error: ' + err.message); + } + }); } }); }); @@ -441,6 +475,50 @@ class OnvifServer { getHostname() { return this.config.hostname; } + + shutdown() { + return new Promise((resolve) => { + let closeCount = 0; + const totalToClose = 2; // server + discovery socket + + const checkComplete = () => { + closeCount++; + if (closeCount >= totalToClose) { + this.logger.info(`Shutdown complete for ${this.config.name}`); + resolve(); + } + }; + + // Close HTTP server + if (this.server) { + this.server.close((err) => { + if (err) { + this.logger.error('Error closing HTTP server: ' + err.message); + } + checkComplete(); + }); + } else { + checkComplete(); + } + + // Close discovery socket + if (this.discoverySocket) { + try { + this.discoverySocket.close(() => { + checkComplete(); + }); + } catch (err) { + this.logger.error('Error closing discovery socket: ' + err.message); + checkComplete(); + } + } else { + checkComplete(); + } + + // Clear cached snapshot + this.snapshotCache = null; + }); + } }; function createServer(config) { From e526caaa48616152c2329790a81a043d9f07853d Mon Sep 17 00:00:00 2001 From: Ian Langworth Date: Wed, 28 Jan 2026 16:50:18 -0800 Subject: [PATCH 2/4] Fix additional memory leaks in TCP proxies and SOAP client - Store and properly clean up TCP proxy instances on shutdown - Fix 'this' binding in HTTP server listen callback - Clean up SOAP client HTTP agent in config-builder - Restore Date.prototype.getUTCHours after modification to prevent global pollution - Add try-finally blocks to ensure cleanup happens even on errors Co-authored-by: Claude Code --- main.js | 14 ++++++++- src/config-builder.js | 68 ++++++++++++++++++++++++++++--------------- src/onvif-server.js | 3 +- 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/main.js b/main.js index 9ecf62e..be1132e 100644 --- a/main.js +++ b/main.js @@ -86,6 +86,7 @@ if (args) { } let proxies = {}; + let proxyServers = []; let servers = []; for (let onvifConfig of config.onvif) { @@ -116,7 +117,8 @@ if (args) { for (let destinationAddress in proxies) { for (let sourcePort in proxies[destinationAddress]) { logger.info(`Starting tcp proxy from port ${sourcePort} to ${destinationAddress}:${proxies[destinationAddress][sourcePort]} ...`); - tcpProxy.createProxy(sourcePort, destinationAddress, proxies[destinationAddress][sourcePort]); + const proxyServer = tcpProxy.createProxy(sourcePort, destinationAddress, proxies[destinationAddress][sourcePort]); + proxyServers.push(proxyServer); logger.info(' Started!'); logger.info(''); } @@ -126,6 +128,16 @@ if (args) { const gracefulShutdown = async (signal) => { logger.info(`Received ${signal}, shutting down gracefully...`); + // Shutdown all TCP proxies + logger.info('Closing TCP proxies...'); + for (const proxy of proxyServers) { + try { + proxy.end(); + } catch (err) { + logger.error('Error closing TCP proxy: ' + err.message); + } + } + // Shutdown all ONVIF servers const shutdownPromises = servers.map(server => server.shutdown()); await Promise.all(shutdownPromises); diff --git a/src/config-builder.js b/src/config-builder.js index 51d5ed3..6349941 100644 --- a/src/config-builder.js +++ b/src/config-builder.js @@ -14,20 +14,22 @@ async function createConfig(hostname, username, password) { hasNonce: true, passwordType: 'PasswordDigest' }; - + let client = await soap.createClientAsync('./wsdl/media_service.wsdl', options); - client.setEndpoint(`http://${hostname}/onvif/device_service`); - client.setSecurity(new soap.WSSecurity(username, password, securityOptions)); - let hostport = 80; - if (hostname.indexOf(':') > -1) { - hostport = parseInt(hostname.substr(hostname.indexOf(':') + 1)); - hostname = hostname.substr(0, hostname.indexOf(':')); - } + // Ensure client is cleaned up on error or completion + try { + client.setEndpoint(`http://${hostname}/onvif/device_service`); + client.setSecurity(new soap.WSSecurity(username, password, securityOptions)); - let cameras = {}; + let hostport = 80; + if (hostname.indexOf(':') > -1) { + hostport = parseInt(hostname.substr(hostname.indexOf(':') + 1)); + hostname = hostname.substr(0, hostname.indexOf(':')); + } + + let cameras = {}; - try { let profiles = await client.GetProfilesAsync({}); for (let profile of profiles[0].Profiles) { let videoSource = profile.VideoSourceConfiguration.SourceToken; @@ -53,18 +55,13 @@ async function createConfig(hostname, username, password) { profile.snapshotUri = snapshotUri[0].MediaUri.Uri; cameras[videoSource].push(profile); } - } catch (err) { - if (err.root && err.root.Envelope && err.root.Envelope.Body && err.root.Envelope.Body.Fault && err.root.Envelope.Body.Fault.Reason && err.root.Envelope.Body.Fault.Reason.Text) - throw `Error: ${err.root.Envelope.Body.Fault.Reason.Text['$value']}`; - throw `Error: ${err.message}`; - } - let config = { - onvif: [] - }; + let config = { + onvif: [] + }; - let serverPort = 8081; - for (let camera in cameras) { + let serverPort = 8081; + for (let camera in cameras) { let mainStream = cameras[camera][0]; let subStream = cameras[camera][cameras[camera].length > 1 ? 1 : 0]; @@ -117,16 +114,31 @@ async function createConfig(hostname, username, password) { } }; - config.onvif.push(cameraConfig); - serverPort++; - } + config.onvif.push(cameraConfig); + serverPort++; + } - return config; + return config; + } catch (err) { + if (err.root && err.root.Envelope && err.root.Envelope.Body && err.root.Envelope.Body.Fault && err.root.Envelope.Body.Fault.Reason && err.root.Envelope.Body.Fault.Reason.Text) + throw `Error: ${err.root.Envelope.Body.Fault.Reason.Text['$value']}`; + throw `Error: ${err.message}`; + } finally { + // Clean up SOAP client to prevent memory leaks + if (client && client.httpClient) { + // Destroy any active HTTP agent connections + if (client.httpClient.agent && client.httpClient.agent.destroy) { + client.httpClient.agent.destroy(); + } + } + } } exports.createConfig = async function(hostname, username, password) { let config; + let originalGetUTCHours = null; + try { config = await createConfig(hostname, username, password); } catch (err) { @@ -135,6 +147,9 @@ exports.createConfig = async function(hostname, username, password) { console.log('Retrying...') var utcHours = (new Date()).getUTCHours(); + + // Save original method before modifying prototype + originalGetUTCHours = Date.prototype.getUTCHours; Date.prototype.getUTCHours = function() { return utcHours + 1; } @@ -145,6 +160,11 @@ exports.createConfig = async function(hostname, username, password) { console.log(err); } } + } finally { + // Restore original Date.prototype.getUTCHours if it was modified + if (originalGetUTCHours !== null) { + Date.prototype.getUTCHours = originalGetUTCHours; + } } return config; diff --git a/src/onvif-server.js b/src/onvif-server.js index 184b455..6254959 100644 --- a/src/onvif-server.js +++ b/src/onvif-server.js @@ -358,7 +358,8 @@ class OnvifServer { } startServer() { - this.server = http.createServer(this.listen); + // Bind 'this' context to listen method to prevent context loss + this.server = http.createServer(this.listen.bind(this)); // Add error handler to prevent crashes this.server.on('error', (err) => { From 5c13294247218b634775d74480ca38b1a1fef7fc Mon Sep 17 00:00:00 2001 From: Ian Langworth Date: Wed, 28 Jan 2026 16:52:44 -0800 Subject: [PATCH 3/4] Fix xml2js parser memory leak and optimize discovery responses - Create reusable xml2js Parser instance instead of new parser on every discovery message - Cache XML parser options to avoid recreating array/object on each parse - Extract discovery response generation to separate method for better code organization - Replace deprecated url.parse with modern URL API - Add error handling for XML parse failures in discovery This fixes a critical memory leak where a new Parser instance was created for every ONVIF discovery probe, which could happen hundreds of times per hour. Co-authored-by: Claude Code --- src/onvif-server.js | 92 ++++++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/src/onvif-server.js b/src/onvif-server.js index 6254959..339aba6 100644 --- a/src/onvif-server.js +++ b/src/onvif-server.js @@ -33,6 +33,10 @@ class OnvifServer { this.snapshotCache = null; this.debugListenersAdded = false; + // Create reusable XML parser to prevent memory leak from creating new parsers on every discovery message + this.xmlParserOptions = { tagNameProcessors: [xml2js['processors'].stripPrefix] }; + this.xmlParser = new xml2js.Parser(this.xmlParserOptions); + if (!this.config.hostname) this.config.hostname = getIpAddressFromMac(this.config.mac); @@ -335,8 +339,17 @@ class OnvifServer { } listen(request, response) { - let action = url.parse(request.url, true).pathname; - if (action == '/snapshot.png') { + // Use modern URL API instead of deprecated url.parse + let pathname; + try { + const parsedUrl = new URL(request.url, `http://${request.headers.host || 'localhost'}`); + pathname = parsedUrl.pathname; + } catch (err) { + // Fallback for malformed URLs + pathname = request.url.split('?')[0]; + } + + if (pathname == '/snapshot.png') { // Cache snapshot image to avoid repeated file I/O if (!this.snapshotCache) { try { @@ -409,7 +422,13 @@ class OnvifServer { }); this.discoverySocket.on('message', (message, remote) => { - xml2js.parseString(message.toString(), { tagNameProcessors: [xml2js['processors'].stripPrefix] }, (err, result) => { + // Use reusable parser instance instead of creating new one each time + this.xmlParser.parseString(message.toString(), (err, result) => { + if (err) { + this.logger.error('XML parse error in discovery: ' + err.message); + return; + } + let probeUuid = result['Envelope']['Header'][0]['MessageID'][0]; let probeType = ''; try { @@ -420,43 +439,13 @@ class OnvifServer { if (typeof probeType === 'object') probeType = probeType._; - + if (typeof probeUuid === 'object') probeUuid = probeUuid._; if (probeType === '' || probeType.indexOf('NetworkVideoTransmitter') > -1) { - let response = - ` - - - uuid:${uuid.v1()} - ${probeUuid} - http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous - http://schemas.xmlsoap.org/ws/2005/04/discovery/ProbeMatches - - - - - - - urn:uuid:${this.config.uuid} - - dn:NetworkVideoTransmitter - - onvif://www.onvif.org/type/video_encoder - onvif://www.onvif.org/type/ptz - onvif://www.onvif.org/hardware/Onvif - onvif://www.onvif.org/name/Cardinal - onvif://www.onvif.org/location/ - - http://${this.config.hostname}:${this.config.ports.server}/onvif/device_service - 1 - - - - `; - this.discoveryMessageNo++; + let response = this.generateDiscoveryResponse(probeUuid, this.discoveryMessageNo); let responseBuffer = Buffer.from(response); // Reuse existing socket instead of creating new ones (memory leak fix) this.discoverySocket.send(responseBuffer, 0, responseBuffer.length, remote.port, remote.address, (err) => { @@ -477,6 +466,39 @@ class OnvifServer { return this.config.hostname; } + generateDiscoveryResponse(probeUuid, messageNo) { + // Pre-generate discovery response template to reduce string allocation overhead + return ` + + + uuid:${uuid.v1()} + ${probeUuid} + http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + http://schemas.xmlsoap.org/ws/2005/04/discovery/ProbeMatches + + + + + + + urn:uuid:${this.config.uuid} + + dn:NetworkVideoTransmitter + + onvif://www.onvif.org/type/video_encoder + onvif://www.onvif.org/type/ptz + onvif://www.onvif.org/hardware/Onvif + onvif://www.onvif.org/name/Cardinal + onvif://www.onvif.org/location/ + + http://${this.config.hostname}:${this.config.ports.server}/onvif/device_service + 1 + + + +`; + } + shutdown() { return new Promise((resolve) => { let closeCount = 0; From 7a28e09ec1ec61a670b7df8e381a8370fee30acd Mon Sep 17 00:00:00 2001 From: Ian Langworth Date: Wed, 28 Jan 2026 16:53:18 -0800 Subject: [PATCH 4/4] Add GitHub Actions workflow for automatic Docker builds - Builds and pushes Docker image to ghcr.io on every commit to main - Also builds (but doesn't push) on pull requests for testing - Supports multi-platform builds (amd64 and arm64) - Uses GitHub Actions cache for faster builds - Automatically tags images with branch name, commit SHA, and 'latest' The workflow uses GITHUB_TOKEN for authentication, which is automatically provided by GitHub Actions - no manual token setup required. Co-authored-by: Claude Code --- .github/workflows/docker-build.yml | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/docker-build.yml diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..9d313e9 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,58 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64