diff --git a/scripts/deep-check-device.sh b/scripts/deep-check-device.sh new file mode 100755 index 0000000..3d6a35b --- /dev/null +++ b/scripts/deep-check-device.sh @@ -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 '[^<]*' | sed 's///' || true) + SSDP_MANUFACTURER=$(echo "$XML" | grep -o -m1 '[^<]*' | sed 's///' || true) + SSDP_MODEL=$(echo "$XML" | grep -o -m1 '[^<]*' | sed 's///' || 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 '[^<]*' | 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