Skip to content

Instantly share code, notes, and snippets.

@Slyke
Last active November 1, 2023 10:12
Show Gist options
  • Save Slyke/7d5b290f1d5695fdd79f5e0a08837c93 to your computer and use it in GitHub Desktop.
Save Slyke/7d5b290f1d5695fdd79f5e0a08837c93 to your computer and use it in GitHub Desktop.
DHCP alerts (NodeRed/sh)
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
#!/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
[{"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"]]}]
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;
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;
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