Files
homelab-monitor/scripts/deep-check-device.sh
T
jhodgkin d0d6ae95f1
CI / web (push) Successful in 16s
CI / api (push) Successful in 21s
Add optional Fingerbank device fingerprinting to deep-check
Folded into the existing on-demand Deep check button: queries
Fingerbank's interrogate API with the device's MAC plus the SSDP
SERVER header when deep-check-device.sh finds one, showing the
confidence band alongside the result. Runs directly from the API
container (no host-level access needed, just an outbound HTTPS call),
unlike the SSDP/mDNS steps.

Confirmed via direct testing: without DHCP fingerprint data (which we
structurally don't have, not being the DHCP server), MAC-only queries
often can't get past manufacturer-level confidence -- same info the
free OUI lookup already provides. Documented honestly in
docs/device-discovery.md rather than overselling it. Still worth
having as opt-in enrichment for devices that do expose richer signals.

Gated behind optional FINGERBANK_API_KEY -- missing key, API errors,
or no match all degrade gracefully without affecting the rest of
deep-check's local findings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:35:35 -06:00

132 lines
5.0 KiB
Bash
Executable File

#!/bin/bash
# Forced command for a dedicated SSH key entry on CT122's OWN root user (see
# docs/device-discovery.md). Unlike the generic collector forced-commands on
# omv/ripper, this one is parameterized: forced commands ignore whatever the
# client literally requested, but OpenSSH still exposes it via
# $SSH_ORIGINAL_COMMAND, which this script reads and validates strictly
# before using -- never passed to a shell for interpretation.
#
# Runs a handful of *targeted, on-demand* active probes against a single LAN
# IP (admin-triggered from the dashboard, not automatic/scheduled): mDNS
# reverse resolve, SSDP/UPnP query (many smart-home devices announce a
# friendlyName/manufacturer this way), a small curated TCP port scan, and an
# HTTP title/server grab on anything open. Bounded timeouts throughout so a
# single check finishes in well under 15s.
set -euo pipefail
IP="${SSH_ORIGINAL_COMMAND:-}"
if ! [[ "$IP" =~ ^192\.168\.1\.([0-9]{1,3})$ ]]; then
echo '{"error":"invalid or missing target IP"}'
exit 1
fi
OCTET="${BASH_REMATCH[1]}"
if (( OCTET < 1 || OCTET > 254 )); then
echo '{"error":"target IP out of range"}'
exit 1
fi
MDNS=$(timeout 2 avahi-resolve -a "$IP" 2>/dev/null | awk '{print $2}' || true)
RDNS=$(timeout 2 getent hosts "$IP" 2>/dev/null | awk '{print $2}' | head -1 || true)
# Prints LOCATION and SERVER headers (one per line) from the first ssdp:all
# response the target IP sends -- SERVER is a useful extra fingerprinting
# signal (e.g. "Linux/3.10 UPnP/1.0 MiniDLNA/1.1") beyond just the device
# descriptor XML, fed to Fingerbank as an upnp_user_agents hint.
SSDP_RAW=$(timeout 4 python3 - "$IP" <<'PYEOF' || true
import socket, sys, time
target = sys.argv[1]
msg = "\r\n".join([
"M-SEARCH * HTTP/1.1", "HOST: 239.255.255.250:1900",
'MAN: "ssdp:discover"', "MX: 3", "ST: ssdp:all", "", ""
]).encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(3)
sock.sendto(msg, ("239.255.255.250", 1900))
start = time.time()
while time.time() - start < 3:
try:
data, addr = sock.recvfrom(4096)
if addr[0] == target:
location = server = ""
for line in data.decode(errors="replace").split("\r\n"):
if line.lower().startswith("location:"):
location = line.split(":", 1)[1].strip()
elif line.lower().startswith("server:"):
server = line.split(":", 1)[1].strip()
print(location)
print(server)
sys.exit(0)
except socket.timeout:
break
PYEOF
)
SSDP_LOCATION=$(echo "$SSDP_RAW" | sed -n '1p')
SSDP_SERVER=$(echo "$SSDP_RAW" | sed -n '2p')
SSDP_FRIENDLY="" SSDP_MANUFACTURER="" SSDP_MODEL=""
if [[ -n "$SSDP_LOCATION" ]]; then
XML=$(timeout 3 curl -s -m 2 "$SSDP_LOCATION" 2>/dev/null || true)
SSDP_FRIENDLY=$(echo "$XML" | grep -o -m1 '<friendlyName>[^<]*' | sed 's/<friendlyName>//' || true)
SSDP_MANUFACTURER=$(echo "$XML" | grep -o -m1 '<manufacturer>[^<]*' | sed 's/<manufacturer>//' || true)
SSDP_MODEL=$(echo "$XML" | grep -o -m1 '<modelName>[^<]*' | sed 's/<modelName>//' || true)
fi
PORTS=(21 22 23 80 443 554 5000 8000 8008 8009 8060 8080 8443 9100 32400 62078)
OPEN_PORTS_DIR="$(mktemp -d)"
trap 'rm -rf "$OPEN_PORTS_DIR"' EXIT
for p in "${PORTS[@]}"; do
( timeout 1 bash -c "echo >/dev/tcp/$IP/$p" 2>/dev/null && touch "$OPEN_PORTS_DIR/$p" ) &
done
wait
open_ports=()
for p in "${PORTS[@]}"; do
[[ -f "$OPEN_PORTS_DIR/$p" ]] && open_ports+=("$p")
done
http_titles=()
for p in "${open_ports[@]}"; do
case "$p" in
80|8000|8008|8080|8060|5000) scheme="http" ;;
443|8443) scheme="https" ;;
*) continue ;;
esac
title=$(timeout 3 curl -sk -m 2 "$scheme://$IP:$p/" 2>/dev/null | grep -o -m1 -i '<title>[^<]*' | sed -E 's/<title>//i' || true)
server=$(timeout 3 curl -sIk -m 2 "$scheme://$IP:$p/" 2>/dev/null | grep -i '^server:' | head -1 | cut -d: -f2- | tr -d '\r' | sed 's/^ *//' || true)
http_titles+=("$p|$title|$server")
done
python3 - "$IP" "$MDNS" "$RDNS" "$SSDP_LOCATION" "$SSDP_SERVER" "$SSDP_FRIENDLY" "$SSDP_MANUFACTURER" "$SSDP_MODEL" \
"$(IFS=,; echo "${open_ports[*]}")" "$(printf '%s\n' "${http_titles[@]}")" <<'PYEOF'
import json, sys
ip, mdns, rdns, ssdp_loc, ssdp_server, ssdp_friendly, ssdp_mfr, ssdp_model, ports_csv, http_raw = sys.argv[1:11]
http = []
for line in http_raw.splitlines():
if not line:
continue
port, title, server = (line.split("|", 2) + ["", ""])[:3]
http.append({
"port": int(port),
"title": title or None,
"server": server or None,
})
result = {
"ip": ip,
"mdnsHostname": mdns or None,
"reverseDns": rdns or None,
"ssdp": {
"location": ssdp_loc or None,
"server": ssdp_server or None,
"friendlyName": ssdp_friendly or None,
"manufacturer": ssdp_mfr or None,
"modelName": ssdp_model or None,
} if ssdp_loc else None,
"openPorts": [int(p) for p in ports_csv.split(",") if p],
"http": http,
}
print(json.dumps(result))
PYEOF