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/README.md b/README.md index 82550b3..7d026d7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ 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 +``` ## Running with Podman To avoid running with priveledged mode, podman can be used to build and run this image. diff --git a/docker-compose.yml b/docker-compose.yml index b0204b7..55285f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,23 @@ 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 + - DEVICE=${DEVICE:-/dev/ttyACM0} + # Maximum time (in milliseconds) to wait for an advert before health check fails. Default: 3600000 (1 hour) + - ADVERT_TIMEOUT_MS=${ADVERT_TIMEOUT_MS:-3600000} restart: unless-stopped # Only necessary for accessing USB devices, such as a USB companion 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..23bd9cd 100644 --- a/index.mjs +++ b/index.mjs @@ -6,11 +6,45 @@ 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; +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') { + 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(); + } +}).listen(8080); const signData = async (kp, data) => { const json = JSON.stringify(data); @@ -34,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, @@ -70,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')){ @@ -87,14 +123,30 @@ if(device.startsWith('/') || device.startsWith('COM')){ } connection.on('connected', async () => { - console.log(`Connected.`); + log(`Connected.`); + isHealthy = true; connection.setManualAddContacts(); 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', () => { + log('Disconnected. Exiting...'); + process.exit(1); +}); + +connection.on('close', () => { + log('Connection closed. Exiting...'); + process.exit(1); +}); + +connection.on('error', (err) => { + error('Connection error:', err); + process.exit(1); }); connection.on(Constants.PushCodes.LogRxData, async (event) => { @@ -102,7 +154,7 @@ connection.on(Constants.PushCodes.LogRxData, async (event) => { await processPacket(connection, event.raw); } catch(e) { - console.error(e); + error(e); } });