Add deep-check-device.sh (host-level, invoked via SSH forced-command)
CI / web (push) Successful in 16s
CI / api (push) Successful in 23s

Part of #15's fourth piece: on-demand active investigation of a
single unknown device, admin-triggered from the dashboard. mDNS
resolve, targeted SSDP/UPnP query (many smart-home devices announce a
friendlyName/manufacturer this way), curated port scan, HTTP
title/server grab on anything open. Runs on the CT122 host for the
same multicast-needs-real-network-access reason discover-devices.sh
does. Not wired into the API yet -- that's next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:09:28 -06:00
parent b84fd373c2
commit e0ef618f0a
+119
View File
@@ -0,0 +1,119 @@
#!/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)
SSDP_LOCATION=$(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:
for line in data.decode(errors="replace").split("\r\n"):
if line.lower().startswith("location:"):
print(line.split(":", 1)[1].strip())
sys.exit(0)
except socket.timeout:
break
PYEOF
)
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_FRIENDLY" "$SSDP_MANUFACTURER" "$SSDP_MODEL" \
"$(IFS=,; echo "${open_ports[*]}")" "$(printf '%s\n' "${http_titles[@]}")" <<'PYEOF'
import json, sys
ip, mdns, rdns, ssdp_loc, ssdp_friendly, ssdp_mfr, ssdp_model, ports_csv, http_raw = sys.argv[1:10]
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,
"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