-
-
Save Slyke/7d5b290f1d5695fdd79f5e0a08837c93 to your computer and use it in GitHub Desktop.
DHCP alerts (NodeRed/sh)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Instructions: | |
1. Install 'arp_monitor.sh' file on DHCP server and setup cronjob. | |
2. Copy the flow from flow.json and import into Nodered. | |
The '.function-node' Javascript files are provided for ease of reading. The code within these files is already in the flow. | |
Take note of the ARP debouncing. You may want to adjust the timings if you are getting false online/offline alerts, or it isn't quick enough for you. | |
Variabes are: | |
arp_monitor.sh: | |
ARP_MAX_AGE = (Default: 120 seconds) How long a MAC stays in the ARP table before being cleared or rechecked. | |
Flow_example.json (Node: Compare and update cache): | |
intervalDiff = (Default: 10000ms) How far apart in time 2 ARP events can be (per MAC in 'previousArpStates'). If longer than this, then it that entry and all future entries in the current list are ignored (this is in case of network outage). | |
maxPreviousArpStates = (Default: 8) How many previous states should be saved. | |
arpCheckPreviousStates = (Default: 3) How many ARP events for a particular MAC need to be the same before the state is updated. Ensure this is always less than maxPreviousArpStates | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/sh | |
# Crontab installation: | |
# * * * * * /bin/sh /root/arp_monitor.sh > /root/arp_monitor_last_run.log 2>&1 | |
ARP_MAX_AGE=120 # You may want to fiddle with this | |
LOCKFILE="/tmp/arpmonitor.lock" | |
OUTPUT_FILE="/tmp/arpmonitor_output.json" | |
URL="http://your-nodered./network/arp-sync" | |
MAX_LOOPS=15 | |
MAX_TIME=55 | |
WAIT_BETWEEN_LOOPS=5 | |
START=$(date +%s) | |
for arg in "$@" | |
do | |
if [ "$arg" == "--rmlock" ]; then | |
if [ -f $LOCKFILE ]; then | |
rm $LOCKFILE | |
echo "File $LOCKFILE deleted." | |
exit 0 | |
else | |
echo "File $LOCKFILE does not exist." | |
exit 0 | |
fi | |
fi | |
done | |
if [ -e "${LOCKFILE}" ]; then | |
echo "${START}: Lockfile exists, exiting..." | |
exit 1 | |
fi | |
sysctl net.link.ether.inet.max_age=120 | |
touch "${LOCKFILE}" | |
echo "${START}" > "${LOCKFILE}" | |
LOOP_COUNT=0 | |
while [ $LOOP_COUNT -lt $MAX_LOOPS ] && [ $LOOP_COUNT -ge 0 ]; do | |
NOW=$(date +%s) | |
DIFF=$(( $NOW - $START )) | |
if [ $DIFF -ge $MAX_TIME ]; then | |
echo "${MAX_TIME} seconds passed, exiting..." | |
rm -f "${LOCKFILE}" | |
exit 0 | |
fi | |
OUTPUT=$(/usr/sbin/arp --libxo json -a) | |
echo "${OUTPUT}" > "${OUTPUT_FILE}" # for debugging | |
if [ "${OUTPUT}" == "DELETE ALL" ]; then | |
/usr/sbin/arp -d -a | |
elif [[ "${OUTPUT}" == DELETE* ]]; then | |
IP_ADDRESS=$(echo "${OUTPUT}" | awk '{print $2}' | cut -d' ' -f1 | grep -oE '[0-9.]+') | |
/usr/sbin/arp -d ${IP_ADDRESS} | |
else | |
curl -X POST -H "Content-Type: application/json" -d "${OUTPUT}" ${URL} --connect-timeout 5 | |
fi | |
LOOP_COUNT=$((LOOP_COUNT+1)) | |
sleep $WAIT_BETWEEN_LOOPS | |
done | |
echo "Loop failed to correctly break (Loops: ${LOOP_COUNT}). Exiting..." | |
exit 2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[{"id":"7f0b5f8034f5c8ce","type":"http response","z":"73a73f72608f1973","name":"","statusCode":"","headers":{},"x":410,"y":500,"wires":[]},{"id":"bdf8be4ae9b82211","type":"switch","z":"73a73f72608f1973","name":"Update Switch","property":"req.params.name","propertyType":"msg","rules":[{"t":"eq","v":"arp-sync","vt":"str"},{"t":"else"}],"checkall":"true","repair":false,"outputs":2,"x":440,"y":420,"wires":[["5f29ef15fd9489c8"],[]]},{"id":"f11d350b55b43e14","type":"http in","z":"73a73f72608f1973","name":"","url":"/network/:name?","method":"post","upload":false,"swaggerDoc":"","x":120,"y":420,"wires":[["7f0b5f8034f5c8ce","bdf8be4ae9b82211"]]},{"id":"5f29ef15fd9489c8","type":"function","z":"73a73f72608f1973","name":"Compare and update cache","func":"const arpCheckPreviousStates = 3;\nconst maxPreviousArpStates = 8;\nconst intervalDiff = 10000;\n\nconst routerArpTable = global.get('router-arp-table', 'memoryOnly') ?? {};\n\nconst arpPayload = msg.payload;\nconst currentTime = new Date().getTime();\narpPayload.syncTime = currentTime;\n\nconst updatedArpCache = JSON.parse(JSON.stringify(routerArpTable));\n\n// Create structure if it doesn't exist\nif (!Array.isArray(updatedArpCache?.arp?.[\"arp-cache\"])) {\n updatedArpCache.__version = updatedArpCache?.__version ?? '0';\n updatedArpCache.arp = {...(updatedArpCache?.arp ?? {})};\n updatedArpCache.arp[\"arp-cache\"] = [];\n}\n\nconst newMacAddresses = new Set((arpPayload?.arp?.[\"arp-cache\"] ?? []).map((item) => item[\"mac-address\"]));\nconst storedMacAddresses = new Set(updatedArpCache.arp[\"arp-cache\"].map((item) => item[\"mac-address\"]));\n\n(updatedArpCache?.arp?.[\"arp-cache\"] ?? []).forEach((item) => {\n const newConnectionState = newMacAddresses.has(item[\"mac-address\"]) ? 'online' : 'offline';\n const newArpEntry = arpPayload?.arp?.[\"arp-cache\"].find((arpItem) => arpItem[\"mac-address\"] === item[\"mac-address\"]) ?? {};\n \n item.expires = newArpEntry.expires;\n item[\"mac-address\"] = item?.[\"mac-address\"] ?? newArpEntry[\"mac-address\"];\n item.hostname = newArpEntry.hostname ?? item.hostname;\n item[\"ip-address\"] = newArpEntry[\"ip-address\"] ?? item[\"ip-address\"];\n item.interface = newArpEntry.interface ?? item.interface;\n item.type = newArpEntry.type ?? item.type;\n \n item.previousArpStates = item.previousArpStates || [];\n if (item.previousArpStates.length >= maxPreviousArpStates) {\n item.previousArpStates.pop();\n }\n item.previousArpStates.unshift({ connectionState: newConnectionState, stateTime: currentTime });\n\n item.connectionState = newConnectionState;\n let previousTimestamp = currentTime;\n item.changedSinceLastCheck = false;\n const statesToCheck = ['offline', 'online'];\n \n // Sometimes a device will leave the ARP table temporarily for a few seconds, which leads to false online/offline events.\n // Debounce the recent previous states to ensure it's persistantly online/offline.\n if ((item?.previousArpStates?.[0]?.stateTime ?? false) && (item?.previousArpStates?.[0]?.connectionState ?? false)) {\n let weightedChangingConnectionState = item?.previousArpStates?.[0]?.connectionState;\n if (Math.abs(currentTime - item.previousArpStates[0].stateTime) < intervalDiff) {\n for (let i = 1; i < arpCheckPreviousStates; i++) {\n if ((item.previousArpStates?.[i]?.connectionState ?? 'unknowni') !== (item.previousArpStates?.[0]?.connectionState ?? 'unknown0')) {\n weightedChangingConnectionState = `unknown-${i}_0-state_mismatch`;\n break;\n }\n \n // Ensure each stateTime is within 10 seconds of the preceding one\n if (Math.abs(item.previousArpStates[i].stateTime - item.previousArpStates[i - 1].stateTime) > intervalDiff) {\n weightedChangingConnectionState = `unknown-${i}_in1-lt_intdiff`;\n break;\n }\n }\n \n if (item?.previousArpStates?.[arpCheckPreviousStates]?.connectionState === item?.previousArpStates?.[0]?.connectionState) {\n weightedChangingConnectionState = `unknown-mxp${arpCheckPreviousStates}_0-state_match`;\n }\n item.changedSinceLastCheck = statesToCheck.includes(weightedChangingConnectionState);\n // item.weightedChangingConnectionState = weightedChangingConnectionState; // For debugging\n }\n }\n});\n\narpPayload.arp[\"arp-cache\"].forEach((item) => {\n if (!storedMacAddresses.has(item[\"mac-address\"])) {\n updatedArpCache.arp[\"arp-cache\"].push({ ...item, connectionState: 'online', previousArpStates: [{ connectionState: 'online', stateTime: currentTime }] });\n }\n});\n\nglobal.set('router-arp-table', updatedArpCache, 'memoryOnly');\n\nmsg.updatedArpCache = updatedArpCache;\nmsg.newMacAddresses = newMacAddresses;\n\nreturn msg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":720,"y":420,"wires":[["ff7c1c541e5d6e36"]]},{"id":"ff7c1c541e5d6e36","type":"function","z":"73a73f72608f1973","name":"Filter updated arp events","func":"const changedEntries = msg.updatedArpCache.arp[\"arp-cache\"].filter((item) => {\n if (item.changedSinceLastCheck) {\n return true;\n }\n\n return false;\n});\n\nmsg.changedEntries = changedEntries;\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1030,"y":420,"wires":[["121279f78c68449b"]]},{"id":"121279f78c68449b","type":"function","z":"73a73f72608f1973","name":"","func":"const macAddresses = {\n 'ab:cd:ef:12:34:56': \"Slyke's Phone\"\n};\n\nconst messages = [];\n\nObject.keys(macAddresses).forEach((macAddress) => {\n const foundChangedState = msg.changedEntries.find((item) => item[\"mac-address\"] === macAddress);\n \n if (foundChangedState) {\n if (foundChangedState.connectionState === 'online') {\n messages.push(`${macAddresses[macAddress]} has entered the building!`);\n } else if (foundChangedState.connectionState === 'offline') {\n messages.push(`${macAddresses[macAddress]} has left the building!`);\n }\n }\n});\n\nif (messages.length > 0) {\n msg.payload = messages.join('\\n');\n return msg;\n}\n\nreturn null;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1340,"y":420,"wires":[["53636a946865c4c6","c1ae79b7d5986cec"]]},{"id":"c1ae79b7d5986cec","type":"debug","z":"73a73f72608f1973","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1610,"y":420,"wires":[]},{"id":"3f2a3a9720c29704","type":"group","z":"73a73f72608f1973","name":"Debug","style":{"label":true},"nodes":["da4938ee4d5dea4c","cd0c4916b00f9af9","1532f072dca8ea44","e13236b02c390781","df478dc6734805bf","ab7b90aaf6d1f63e","f78bf5c2e4d6849a","cd89099108f9932b","16d4db2730c16510","5b2bac57d31cae86","372c3134c969990b"],"x":14,"y":19,"w":732,"h":262},{"id":"da4938ee4d5dea4c","type":"inject","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"Get arp cache","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":150,"y":120,"wires":[["cd0c4916b00f9af9"]]},{"id":"cd0c4916b00f9af9","type":"function","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"","func":"const routerArpTable = global.get('router-arp-table', 'memoryOnly') ?? {};\n\nmsg.routerArpTable = routerArpTable;\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":120,"wires":[["1532f072dca8ea44"]]},{"id":"1532f072dca8ea44","type":"debug","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"routerArpTable","targetType":"msg","statusVal":"","statusType":"auto","x":570,"y":100,"wires":[]},{"id":"e13236b02c390781","type":"inject","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"Clear arp cache","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":140,"y":60,"wires":[["df478dc6734805bf"]]},{"id":"df478dc6734805bf","type":"function","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"","func":"global.set('router-arp-table', {}, 'memoryOnly');\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":60,"wires":[[]]},{"id":"ab7b90aaf6d1f63e","type":"inject","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"Get state by MAC","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"ab:cd:ef:12:34:56","payloadType":"str","x":160,"y":160,"wires":[["f78bf5c2e4d6849a"]]},{"id":"f78bf5c2e4d6849a","type":"function","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"","func":"const routerArpTable = global.get('router-arp-table', 'memoryOnly') ?? {};\n\nmsg.routerArpTable = routerArpTable?.arp?.[\"arp-cache\"]?.find?.((item) => item?.[\"mac-address\"] === msg.payload);\n\nmsg.routerArpTableNeat = JSON.stringify(msg.routerArpTable, null, 2);\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":160,"wires":[["cd89099108f9932b","16d4db2730c16510"]]},{"id":"cd89099108f9932b","type":"debug","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"routerArpTableNeat","targetType":"msg","statusVal":"","statusType":"auto","x":590,"y":180,"wires":[]},{"id":"16d4db2730c16510","type":"debug","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"routerArpTable","targetType":"msg","statusVal":"","statusType":"auto","x":570,"y":140,"wires":[]},{"id":"5b2bac57d31cae86","type":"inject","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"Phone Offline","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"__version\":\"1\",\"arp\":{\"arp-cache\":[{\"hostname\":\"?\",\"ip-address\":\"192.168.1.1\",\"mac-address\":\"aa:bb:cc:dd:ee:ff\",\"interface\":\"igb0\",\"expires\":120,\"type\":\"ethernet\"}]}}","payloadType":"json","x":150,"y":240,"wires":[["5f29ef15fd9489c8"]]},{"id":"372c3134c969990b","type":"inject","z":"73a73f72608f1973","g":"3f2a3a9720c29704","name":"Phone Online","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"__version\":\"1\",\"arp\":{\"arp-cache\":[{\"hostname\":\"?\",\"ip-address\":\"192.168.1.10\",\"mac-address\":\"ab:cd:ef:12:34:56\",\"interface\":\"igb1\",\"expires\":83,\"type\":\"ethernet\"},{\"hostname\":\"?\",\"ip-address\":\"192.168.1.1\",\"mac-address\":\"aa:bb:cc:dd:ee:ff\",\"interface\":\"igb0\",\"expires\":120,\"type\":\"ethernet\"}]}}","payloadType":"json","x":370,"y":240,"wires":[["5f29ef15fd9489c8"]]}] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const macAddresses = { | |
'ab:cd:ef:12:34:56': "Slyke's Phone" | |
}; | |
const messages = []; | |
Object.keys(macAddresses).forEach((macAddress) => { | |
const foundChangedState = msg.changedEntries.find((item) => item["mac-address"] === macAddress); | |
if (foundChangedState) { | |
if (foundChangedState.connectionState === 'online') { | |
messages.push(`${macAddresses[macAddress]} has entered the building!`); | |
} else if (foundChangedState.connectionState === 'offline') { | |
messages.push(`${macAddresses[macAddress]} has left the building!`); | |
} | |
} | |
}); | |
if (messages.length > 0) { | |
msg.payload = messages.join('\n'); | |
return msg; | |
} | |
return null; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const arpCheckPreviousStates = 3; | |
const maxPreviousArpStates = 8; | |
const intervalDiff = 10000; | |
const routerArpTable = global.get('router-arp-table', 'memoryOnly') ?? {}; | |
const arpPayload = msg.payload; | |
const currentTime = new Date().getTime(); | |
arpPayload.syncTime = currentTime; | |
const updatedArpCache = JSON.parse(JSON.stringify(routerArpTable)); | |
// Create structure if it doesn't exist | |
if (!Array.isArray(updatedArpCache?.arp?.["arp-cache"])) { | |
updatedArpCache.__version = updatedArpCache?.__version ?? '0'; | |
updatedArpCache.arp = {...(updatedArpCache?.arp ?? {})}; | |
updatedArpCache.arp["arp-cache"] = []; | |
} | |
const newMacAddresses = new Set((arpPayload?.arp?.["arp-cache"] ?? []).map((item) => item["mac-address"])); | |
const storedMacAddresses = new Set(updatedArpCache.arp["arp-cache"].map((item) => item["mac-address"])); | |
(updatedArpCache?.arp?.["arp-cache"] ?? []).forEach((item) => { | |
const newConnectionState = newMacAddresses.has(item["mac-address"]) ? 'online' : 'offline'; | |
const newArpEntry = arpPayload?.arp?.["arp-cache"].find((arpItem) => arpItem["mac-address"] === item["mac-address"]) ?? {}; | |
item.expires = newArpEntry.expires; | |
item["mac-address"] = item?.["mac-address"] ?? newArpEntry["mac-address"]; | |
item.hostname = newArpEntry.hostname ?? item.hostname; | |
item["ip-address"] = newArpEntry["ip-address"] ?? item["ip-address"]; | |
item.interface = newArpEntry.interface ?? item.interface; | |
item.type = newArpEntry.type ?? item.type; | |
item.previousArpStates = item.previousArpStates || []; | |
if (item.previousArpStates.length >= maxPreviousArpStates) { | |
item.previousArpStates.pop(); | |
} | |
item.previousArpStates.unshift({ connectionState: newConnectionState, stateTime: currentTime }); | |
item.connectionState = newConnectionState; | |
let previousTimestamp = currentTime; | |
item.changedSinceLastCheck = false; | |
const statesToCheck = ['offline', 'online']; | |
// Sometimes a device will leave the ARP table temporarily for a few seconds, which leads to false online/offline events. | |
// Debounce the recent previous states to ensure it's persistantly online/offline. | |
if ((item?.previousArpStates?.[0]?.stateTime ?? false) && (item?.previousArpStates?.[0]?.connectionState ?? false)) { | |
let weightedChangingConnectionState = item?.previousArpStates?.[0]?.connectionState; | |
if (Math.abs(currentTime - item.previousArpStates[0].stateTime) < intervalDiff) { | |
for (let i = 1; i < arpCheckPreviousStates; i++) { | |
if ((item.previousArpStates?.[i]?.connectionState ?? 'unknowni') !== (item.previousArpStates?.[0]?.connectionState ?? 'unknown0')) { | |
weightedChangingConnectionState = `unknown-${i}_0-state_mismatch`; | |
break; | |
} | |
// Ensure each stateTime is within 10 seconds of the preceding one | |
if (Math.abs(item.previousArpStates[i].stateTime - item.previousArpStates[i - 1].stateTime) > intervalDiff) { | |
weightedChangingConnectionState = `unknown-${i}_in1-lt_intdiff`; | |
break; | |
} | |
} | |
if (item?.previousArpStates?.[arpCheckPreviousStates]?.connectionState === item?.previousArpStates?.[0]?.connectionState) { | |
weightedChangingConnectionState = `unknown-mxp${arpCheckPreviousStates}_0-state_match`; | |
} | |
item.changedSinceLastCheck = statesToCheck.includes(weightedChangingConnectionState); | |
// item.weightedChangingConnectionState = weightedChangingConnectionState; // For debugging | |
} | |
} | |
}); | |
arpPayload.arp["arp-cache"].forEach((item) => { | |
if (!storedMacAddresses.has(item["mac-address"])) { | |
updatedArpCache.arp["arp-cache"].push({ ...item, connectionState: 'online', previousArpStates: [{ connectionState: 'online', stateTime: currentTime }] }); | |
} | |
}); | |
global.set('router-arp-table', updatedArpCache, 'memoryOnly'); | |
msg.updatedArpCache = updatedArpCache; | |
msg.newMacAddresses = newMacAddresses; | |
return msg; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const changedEntries = msg.updatedArpCache.arp["arp-cache"].filter((item) => { | |
if (item.changedSinceLastCheck) { | |
return true; | |
} | |
return false; | |
}); | |
msg.changedEntries = changedEntries; | |
return msg; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment