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) {