From 4e866b896c752f6bace042e6d54b9860f678cc3d Mon Sep 17 00:00:00 2001 From: David Date: Tue, 23 Dec 2025 11:21:01 +0100 Subject: [PATCH 1/4] Added healthchecks and ability to monitor container health from outside --- Dockerfile | 8 +++++++- docker-compose.yml | 11 ++++++++++- index.mjs | 29 +++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index dc52248..159f183 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,17 @@ FROM node:22-alpine +ENV NODE_ENV=production + WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm install && npm cache clean --force COPY . . +RUN chown -R node:node /app + +USER node + CMD ["sh", "-c", "node index.mjs ${DEVICE:-/dev/ttyACM0}"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b0204b7..a74d786 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: map-uploader: build: . + # Optionally expose the port if you want to monitor the container's health externally + # ports: + # - "8080:8080" environment: # Set to the appropriate device for your setup, either /dev/ttyACM0 if connected by USB to a USB companion or IP:port if connected over network to a wifi companion - DEVICE=/dev/ttyACM0 @@ -9,4 +12,10 @@ services: privileged: true # Adjust according to your setup, but this is the default devices: - - /dev/ttyACM0:/dev/ttyACM0 \ No newline at end of file + - /dev/ttyACM0:/dev/ttyACM0 + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 1 + start_period: 5s \ No newline at end of file diff --git a/index.mjs b/index.mjs index 15d6e56..47411eb 100644 --- a/index.mjs +++ b/index.mjs @@ -6,11 +6,24 @@ import { import { KeyPair } from './supercop/index.mjs'; import crypto from 'crypto'; +import http from 'http'; const device = process.argv[2] ?? '/dev/ttyACM0'; const apiURL = 'https://map.meshcore.dev/api/v1/uploader/node'; const seenAdverts = {}; let clientInfo = {}; +let isHealthy = false; + +// Health check server +http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(isHealthy ? 200 : 503); + res.end(isHealthy ? 'OK' : 'Not Ready'); + } else { + res.writeHead(404); + res.end(); + } +}).listen(8080); const signData = async (kp, data) => { const json = JSON.stringify(data); @@ -88,6 +101,7 @@ if(device.startsWith('/') || device.startsWith('COM')){ connection.on('connected', async () => { console.log(`Connected.`); + isHealthy = true; connection.setManualAddContacts(); @@ -97,6 +111,21 @@ connection.on('connected', async () => { console.log('Map uploader waiting for adverts...'); }); +connection.on('disconnected', () => { + console.log('Disconnected. Exiting...'); + process.exit(1); +}); + +connection.on('close', () => { + console.log('Connection closed. Exiting...'); + process.exit(1); +}); + +connection.on('error', (err) => { + console.error('Connection error:', err); + process.exit(1); +}); + connection.on(Constants.PushCodes.LogRxData, async (event) => { try { await processPacket(connection, event.raw); From 632deed5e93a8e8544d7e1ec9c84e9c768b45c89 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 27 Jan 2026 09:51:27 +0100 Subject: [PATCH 2/4] Quality of life improvements for someone running this unattended but with monitoring in place: *Improved health checks if no advert for a certain amount of time (configured in docker compose) the container will go unhealthy. *Improved logging by adding time/date to each line. --- docker-compose.yml | 4 +++- index.mjs | 53 +++++++++++++++++++++++++++++++++------------- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a74d786..8554c6e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,9 @@ services: # - "8080:8080" environment: # Set to the appropriate device for your setup, either /dev/ttyACM0 if connected by USB to a USB companion or IP:port if connected over network to a wifi companion - - DEVICE=/dev/ttyACM0 + - DEVICE=${DEVICE:-/dev/ttyACM0} + # Maximum time (in milliseconds) to wait for an advert before health check fails. Default: 300000 (5 minutes) + - ADVERT_TIMEOUT_MS=300000 restart: unless-stopped # Only necessary for accessing USB devices, such as a USB companion privileged: true diff --git a/index.mjs b/index.mjs index 47411eb..23bd9cd 100644 --- a/index.mjs +++ b/index.mjs @@ -13,12 +13,33 @@ const apiURL = 'https://map.meshcore.dev/api/v1/uploader/node'; const seenAdverts = {}; let clientInfo = {}; let isHealthy = false; +const advertTimeoutMs = parseInt(process.env.ADVERT_TIMEOUT_MS ?? '300000', 10); // milliseconds +let lastAdvertTime = null; + +// Wrapper for console methods that adds timestamp +const formatLog = (level, ...args) => { + const timestamp = new Date().toISOString(); + return [`[${timestamp}] [${level}]`, ...args]; +}; + +const log = (...args) => console.log(...formatLog('INFO', ...args)); +const warn = (...args) => console.warn(...formatLog('WARN', ...args)); +const error = (...args) => console.error(...formatLog('ERROR', ...args)); // Health check server http.createServer((req, res) => { if (req.url === '/health') { - res.writeHead(isHealthy ? 200 : 503); - res.end(isHealthy ? 'OK' : 'Not Ready'); + if (!isHealthy) { + res.writeHead(503); + res.end('Not Ready'); + } else if (lastAdvertTime && Date.now() - lastAdvertTime > advertTimeoutMs) { + const timeoutSec = Math.floor(advertTimeoutMs / 1000); + res.writeHead(503); + res.end(`No adverts received in ${timeoutSec} seconds`); + } else { + res.writeHead(200); + res.end('OK'); + } } else { res.writeHead(404); res.end(); @@ -47,22 +68,24 @@ const processPacket = async (connection, rawPacket) => { const node = { pubKey, name: advert.parsed.name, ts: advert.timestamp, type: advert.parsed.type.toLowerCase() }; if(!advert.isVerified()) { - console.warn('ignoring: signature verification failed', node); + warn('ignoring: signature verification failed', node); return; } if(seenAdverts[pubKey]) { if(seenAdverts[pubKey] >= advert.timestamp) { - console.warn('ignoring: possible replay attack', node); + warn('ignoring: possible replay attack', node); return; } if(advert.timestamp < seenAdverts[pubKey] + 3600) { - console.warn('ignoring: timestamp too new to reupload', node) + warn('ignoring: timestamp too new to reupload', node) return; } } - console.log(`uploading`, node); + lastAdvertTime = Date.now(); + + log(`uploading`, node); const data = { params: { freq: clientInfo.radioFreq / 1000, @@ -83,13 +106,13 @@ const processPacket = async (connection, rawPacket) => { // console.debug('DEBUG: sent request', req); - console.log('sending', requestData) - console.log(await req.json()); + log('sending', requestData) + log(await req.json()); seenAdverts[pubKey] = advert.timestamp; } -console.log(`Connecting to ${device}...`); +log(`Connecting to ${device}...`); let connection; if(device.startsWith('/') || device.startsWith('COM')){ @@ -100,7 +123,7 @@ if(device.startsWith('/') || device.startsWith('COM')){ } connection.on('connected', async () => { - console.log(`Connected.`); + log(`Connected.`); isHealthy = true; connection.setManualAddContacts(); @@ -108,21 +131,21 @@ connection.on('connected', async () => { clientInfo = await connection.getSelfInfo(); clientInfo.kp = KeyPair.from({ publicKey: clientInfo.publicKey, secretKey: (await connection.exportPrivateKey()).privateKey }); - console.log('Map uploader waiting for adverts...'); + log('Map uploader waiting for adverts...'); }); connection.on('disconnected', () => { - console.log('Disconnected. Exiting...'); + log('Disconnected. Exiting...'); process.exit(1); }); connection.on('close', () => { - console.log('Connection closed. Exiting...'); + log('Connection closed. Exiting...'); process.exit(1); }); connection.on('error', (err) => { - console.error('Connection error:', err); + error('Connection error:', err); process.exit(1); }); @@ -131,7 +154,7 @@ connection.on(Constants.PushCodes.LogRxData, async (event) => { await processPacket(connection, event.raw); } catch(e) { - console.error(e); + error(e); } }); From ee92eb33147e6db6fdc0cea18ce235efa652391b Mon Sep 17 00:00:00 2001 From: David Date: Tue, 27 Jan 2026 21:33:56 +0100 Subject: [PATCH 3/4] Adjust default timeout to 1h --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8554c6e..f0906b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,8 @@ services: environment: # Set to the appropriate device for your setup, either /dev/ttyACM0 if connected by USB to a USB companion or IP:port if connected over network to a wifi companion - DEVICE=${DEVICE:-/dev/ttyACM0} - # Maximum time (in milliseconds) to wait for an advert before health check fails. Default: 300000 (5 minutes) - - ADVERT_TIMEOUT_MS=300000 + # Maximum time (in milliseconds) to wait for an advert before health check fails. Default: 3600000 (1 hour) + - ADVERT_TIMEOUT_MS=3600000 restart: unless-stopped # Only necessary for accessing USB devices, such as a USB companion privileged: true From f0d07957d0fce3f634c994a566ca78b0e522c322 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 29 Jan 2026 20:42:45 +0100 Subject: [PATCH 4/4] Add examples, allow configuration from .env --- README.md | 9 +++++++++ docker-compose.yml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0506c9e..0786a1b 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,12 @@ docker-compose build docker-compose up ``` You will be able to inspect the logs. Once everything is working correctly, run the container with ``docker-compose up -d`` to run it in the background. + +### Configuration in Docker +You can override default values with a .env file, such as: +```sh +# Set device to a wifi companion +DEVICE=192.168.10.74:5000 +# Override timeout to 30 mins (if no adverts have been received within this time, the container will report unhealthy and restart automatically) +ADVERT_TIMEOUT_MS=1800000 +``` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f0906b7..55285f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: # Set to the appropriate device for your setup, either /dev/ttyACM0 if connected by USB to a USB companion or IP:port if connected over network to a wifi companion - DEVICE=${DEVICE:-/dev/ttyACM0} # Maximum time (in milliseconds) to wait for an advert before health check fails. Default: 3600000 (1 hour) - - ADVERT_TIMEOUT_MS=3600000 + - ADVERT_TIMEOUT_MS=${ADVERT_TIMEOUT_MS:-3600000} restart: unless-stopped # Only necessary for accessing USB devices, such as a USB companion privileged: true