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 diff --git a/main.js b/main.js index edae20e..be1132e 100644 --- a/main.js +++ b/main.js @@ -86,6 +86,8 @@ if (args) { } let proxies = {}; + let proxyServers = []; + let servers = []; for (let onvifConfig of config.onvif) { let server = onvifServer.createServer(onvifConfig, logger); @@ -95,12 +97,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) @@ -114,12 +117,38 @@ 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(''); } } + // Graceful shutdown on SIGTERM/SIGINT (important for Docker) + 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); + + 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/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 0965cee..339aba6 100644 --- a/src/onvif-server.js +++ b/src/onvif-server.js @@ -30,6 +30,12 @@ class OnvifServer { constructor(config, logger) { this.config = config; this.logger = logger; + 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); @@ -333,11 +339,30 @@ class OnvifServer { } listen(request, response) { - let action = url.parse(request.url, true).pathname; - if (action == '/snapshot.png') { - let image = fs.readFileSync('./resources/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 { + 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'); @@ -346,18 +371,25 @@ 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) => { + 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 +397,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,9 +415,20 @@ 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) => { + // 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 { @@ -390,45 +439,20 @@ 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); - 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 +465,83 @@ class OnvifServer { getHostname() { 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; + 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) {