Saltar a contenido

Hallazgos de reconocimiento — 2026-09-20

Entorno

Servidor Milestone (192.0.2.50)

  • Windows Server 2022 Standard Evaluation, host SRV-MILESTONE
  • Acceso: ssh 192.0.2.50 -l Administrator -i ~/.ssh/id_ed25519
  • XProtect VMS 2026 R1 (26.1.19310.1) — all-in-one: Management Server, Recording Server, Event Server, Mobile Server, API Gateway, Data Collector
  • Device Pack 14.1a (14.1.2294.1) + Legacy Device Pack 3.0a
  • Smart Client 2026 R1 y Management Client instalados localmente
  • .NET 8.0.26 + ASP.NET Core 8 Hosting Bundle presentes
  • SQL Server MSSQLSERVER local, DB Surveillance (+ Surveillance_IDP, Surveillance_IM)
  • sqlcmd disponible en C:\Program Files\Microsoft SQL Server\Client SDK\ODBC\170\Tools\Binn\

Puertos en escucha relevantes

Puerto Proceso Uso
9090 VideoOS.Event.Server.exe (PID 2844) Analytics Events — el que queremos usar
7563 Recording Server streaming
80 / 443 / 22331 http.sys Management Server / API Gateway

El puerto 9090 ya está escuchando, o sea Analytics Events está habilitado en el Event Server. Falta confirmar si tiene lista blanca de IPs de origen.

Plugins MIP ya instalados (C:\Program Files\Milestone\MIPPlugins)

Hanwha: HanwhaVisionCameraSetting, HanwhaVisionIntercom(+Server), HanwhaVisionIPSpeaker, HanwhaVisionLivePlug-in, HanwhaVisionVehicleManagement, AICameraServer, AIPack, AISearch* (Face, LicensePlate, Person, Vehicle, Barcode, RoadAI, Similarity, WiseDetector) Hikvision: HikMngtPlugin, HikANPRPlugin, HikFacePlugin, HikThermalPlugin, HikPeopleCountingPlugin, HikSpeakerPlugin Comunes: AlarmServicePlugin, CommonPlugin, SpeakerActionPlugin

Hay HanwhaVisionPluginServer y HanwhaVisionPluginClient 1.09.00 instalados. Sirven como referencia de estructura de un plugin MIP de terceros, y también hay que verificar que nuestro desarrollo no colisione/duplique con ellos.

Red hacia cámaras

El servidor Windows llega a las cámaras por VPN FortiClient (Ethernet 3 = 192.0.2.200). El host Linux donde corre Claude NO llega a las cámaras: todo sondeo a cámaras debe hacerse vía SSH en el Windows.

Inventario actual en Milestone (DB Surveillance)

SELECT h.Name, h.URI, h.LoginID, h.DetectedModel, h.Enabled, h.DriverProxyName FROM Hardware h;
| Hardware | URI | User | Modelo | Driver | |---|---|---|---|---| | Hanwha Vision TNO-4050T | http://192.0.2.10/ | admin | TNO-4050T | 788 | | HikVision DS-2TD2138-15/QY | http://192.0.2.21/ | admin | DS-2TD2138-15/QY | 793 | | Hikvision DS-2TD2138-25/QY | http://192.0.2.20/ | admin | DS-2TD2138-25/QY | 50001 |

Grupos de cámaras existentes: hanwa (1 cámara: CAM-01), hik (2 cámaras). Los grupos existen por cada DeviceType (Camera, Input, Output, Metadata, AudioInput/Output).

Tablas útiles: Hardware, Devices, DeviceGroups, DeviceGroupMembers. Claves: Hardware.IDHardwareDevices.IDHardware; DeviceGroupMembers une IDDeviceGroupIDDevice. Hardware.EncryptedPassword guarda la credencial (encriptada con DPAPI del servicio — no trivial de descifrar desde afuera).


Cámara de laboratorio: Hanwha TNO-4050T @ 192.0.2.10

Model=TNO-4050T          (térmica)
FirmwareVersion=2.11.13_20260608_R168
CGIVersion=2.6.0
ONVIFVersion=20.12
ConnectedMACAddress=AA:BB:CC:DD:EE:FF
DeviceType=NWC

⚠️ Trampa importante: curl 7.79.1 NO puede autenticar contra esta cámara

C:\Windows\System32\curl.exe en Server 2022 es curl 7.79.1 y falla el handshake Digest contra el lighttpd de la cámara: recibe el 401 con WWW-Authenticate: Digest realm="iPolis_..." charset="UTF-8" qop="auth", reutiliza la conexión y nunca manda la segunda request. Termina con exit=67 (login denied) y salida vacía.

Las credenciales son correctas. El método estaba mal. El script actual del escritorio (eventos_hanwha.ps1) hoy devolvería vacío por esta razón.

Solución: usar System.Net.Http.HttpClient con CredentialCache + "Digest", que sí negocia bien:

Add-Type -AssemblyName System.Net.Http
$cc = New-Object System.Net.CredentialCache
$cc.Add((New-Object Uri("http://$ip/")), "Digest",
        (New-Object System.Net.NetworkCredential($user,$pass)))
$h = New-Object System.Net.Http.HttpClientHandler; $h.Credentials = $cc
$c = New-Object System.Net.Http.HttpClient($h)
$r = $c.GetAsync("http://$ip/stw-cgi/...").Result

En .NET 8 (SocketsHttpHandler) el Digest funciona igual de bien, así que el desarrollo final no tiene este problema.


🎯 EL HALLAZGO CLAVE: la zona SÍ se puede discriminar

El problema

eventstatus.cgi?action=check (lo que usa el script actual) devuelve el evento agregado, sin zona:

Channel.0.VideoAnalytics.Intrusion=False
Channel.0.VideoAnalytics.Appearing=True      ← ¿en qué zona? no se sabe

La solución: &SchemaBased=True

GET /stw-cgi/eventstatus.cgi?msubmenu=eventstatus&action=check&SchemaBased=True
Channel.0.MotionDetection.RegionID.1=False
Channel.0.VideoAnalytics.Passing=False
Channel.0.VideoAnalytics.Passing.LineID.1=False          ← por línea
Channel.0.VideoAnalytics.Intrusion=False
Channel.0.VideoAnalytics.Intrusion.DefinedAreaID.1=False ← por zona
Channel.0.VideoAnalytics.Intrusion.DefinedAreaID.2=False
Channel.0.VideoAnalytics.Entering.DefinedAreaID.1=False
Channel.0.VideoAnalytics.Entering.DefinedAreaID.2=False
Channel.0.VideoAnalytics.Exiting.DefinedAreaID.1=False
Channel.0.VideoAnalytics.Exiting.DefinedAreaID.2=False
Channel.0.VideoAnalytics.Appearing=True
Channel.0.VideoAnalytics.Appearing.DefinedAreaID.1=False
Channel.0.VideoAnalytics.Appearing.DefinedAreaID.2=True  ← ZONA 2 ✅
Channel.0.VideoAnalytics.Loitering.DefinedAreaID.1=False
Channel.0.VideoAnalytics.Loitering.DefinedAreaID.2=False

No hace falta scraping, ni metadata stream RTSP, ni ONVIF PullPoint.

Esquema declarado por la propia cámara

eventstatus.cgi?msubmenu=eventstatusschema&action=view&EventName=VideoAnalytics devuelve el contrato explícito:

Channel.<int>.VideoAnalytics.Passing            / .Passing.LineID.<int>
Channel.<int>.VideoAnalytics.Intrusion          / .Intrusion.DefinedAreaID.<int>
Channel.<int>.VideoAnalytics.Entering           / .Entering.DefinedAreaID.<int>
Channel.<int>.VideoAnalytics.Exiting            / .Exiting.DefinedAreaID.<int>
Channel.<int>.VideoAnalytics.Appearing          / .Appearing.DefinedAreaID.<int>
Channel.<int>.VideoAnalytics.Loitering          / .Loitering.DefinedAreaID.<int>

EventName acepta también AlarmInput, AlarmOutput, MotionDetection, VideoAnalytics, y más (ver attributes.cgi/eventstatus). Esto permite auto-descubrir qué reporta cada modelo en vez de hardcodear.

Mejor patrón de sondeo: action=monitordiff

attributes.cgi/eventstatus expone tres acciones: - check — snapshot puntual (lo que usa el script actual, en un while($true) → polling agresivo) - monitor — long-polling, estado completo, con Periodicity (1..5 s) - monitordiff — long-polling que devuelve sólo lo que cambió

Todas soportan SchemaBased, IncludeTimestamp=True (devuelve Timestamp ISO-8601) y filtro Channel.#.EventType. monitordiff + SchemaBased=True + IncludeTimestamp=True es el patrón correcto: baja latencia, poca CPU en cámara, timestamp de la cámara.

Nota: el SunapiSeqId del script actual no aparece en attributes.cgi para check — parece ignorado. El mecanismo real de secuencia lo maneja monitor/monitordiff.

Geometría de las zonas (para dibujarlas en la UI)

eventsources.cgi?msubmenu=videoanalysis2&action=view (JSON) devuelve ROIs, Lines y DefinedAreas con coordenadas, y por cada DefinedArea el Mode (qué analíticas tiene habilitadas) y duraciones:

{ "DefinedArea": 1, "Type": "Inside",
  "Mode": ["AppearDisappear","Entering","Exiting","Intrusion","Loitering"],
  "Coordinates": [{"x":332,"y":99},{"x":0,"y":621},{"x":458,"y":637},{"x":479,"y":167}],
  "AppearanceDuration": 10, "LoiteringDuration": 17, "IntrusionDuration": 1 }

La cámara no guarda un nombre para cada zona, sólo DefinedAreaID 1..N. El mapeo DefinedAreaID 2 → "Portón Norte" tiene que vivir en nuestra aplicación.

videoanalysis (la v1, texto plano) da lo mismo pero recortado — usar videoanalysis2.

Otros datos

  • eventstatus.cgi?msubmenu=eventscheme&action=viewType=Proprietary (se puede cambiar a ONVIF; no tocar, cambiaría el formato de eventos y podría afectar al driver de Milestone que ya está usando la cámara).
  • Coordenadas en este modelo van en un espacio 480x640 (nótese invertido, cámara térmica 640x480 rotada en el sistema de coordenadas).

Estado de verificación

Ítem Estado
SSH al servidor Milestone ✅ verificado
Inventario de cámaras/grupos desde SQL ✅ verificado
Puerto 9090 escuchando en Event Server ✅ verificado
Credenciales de cámara + Digest vía HttpClient ✅ verificado
Discriminación por zona (SchemaBased=True) ✅ verificado con evento real (zona 2)
Geometría de zonas ✅ verificado
monitordiff probado en vivo ❌ pendiente
Envío real a Analytics Events 9090 ❌ pendiente
Lista blanca de IPs en Analytics Events ❌ pendiente
Contraste con el plugin Hanwha 1.09 ya instalado ❌ pendiente

Segunda ronda — validaciones de la plataforma Milestone

EventTypes: cómo lo resuelve el plugin Hikvision ya instalado ✅

SELECT GeneratorType, COUNT(*) FROM EventTypes GROUP BY GeneratorType:

GeneratorType #
DriverDevice 194
MIPDevice 67
SystemDashboard 43
Device 25
Recorder 19
Server / System / Hardware / DriverHardware / External / Timer / RecorderPlugin resto

Los 67 MIPDevice son todos del mismo IDGenerator 9F6E7E9D-3FD2-49B2-9CA0-F7E7F8F8C3A7 (HikMngtPlugin) y revelan el patrón:

Intrusion_0 .. Intrusion_5            ← índice de zona en el nombre
Line Crossing_0 .. Line Crossing_5
Region Entrance_0 .. Region Entrance_5
Region Exiting_0 .. Region Exiting_5
Temperature Alarm_0 .. _5
Temperature Pre-alarm_0 .. _5
Driving on the Lane Line_0 .. _5

Face Detection, Fire Detection, Illegal Parking,
Object Removal, Speeding, ...               ← sin sufijo: no tienen zona

Conclusión: sufijo _N = índice de zona/regla; sin sufijo = evento sin zona. Es exactamente el patrón que necesitamos y ya está validado en este sistema. Hikvision no crea tipos separados de fin de evento.

⚠️ Matiz: esos tipos son GeneratorType=MIPDevice, que sólo puede registrar un plug-in MIP. Nuestro servicio externo los registrará como Analytics Events (otro generator). El patrón de nombres se copia igual; el mecanismo de registro es distinto. Funcionalmente equivalente para reglas y alarmas, porque el origen del evento sigue siendo la cámara.

Hoy no hay ningún Analytics Event Type definido: los únicos External son los 3 built-in (RequestPlayAudio, RequestStartRecording, RequestStopRecording). Arrancamos de cero, sin riesgo de colisión.

🎯 Credenciales: resuelto sin descifrar nada ✅

No hace falta atacar Hardware.EncryptedPassword (DPAPI) ni pedirle al usuario que cargue 100+ credenciales. El MIP SDK expone la lectura de contraseña, igual que el botón del Management Client. Verificado en vivo:

Install-Module MilestonePSTools -Scope AllUsers -Force -AllowClobber -SkipPublisherCheck
Connect-Vms -ServerAddress "http://localhost" -AcceptEula
Get-VmsHardware | Where-Object { $_.Name -match 'Hanwha' } | Get-VmsHardwarePassword
# → devuelve la contraseña en claro (verificado: coincide con la real)

Implicancia de diseño: el servicio no necesita su propio almacén de credenciales. Se conecta al Management Server con una cuenta de servicio y obtiene credencial + IP de cada cámara desde la config de Milestone, que ya es la fuente de verdad. Alta de cámara nueva = cero configuración extra.

Requisito: la cuenta de servicio necesita permiso de administrador en el VMS para leer contraseñas de hardware.

Nota de bootstrap: en sesión no interactiva hay que preinstalar el proveedor NuGet, si no Install-Module falla con "Windows PowerShell is in NonInteractive mode":

Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers -Confirm:$false
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted

Plataforma de desarrollo ✅

  • El servidor Windows tiene salida a internet (PowerShell Gallery responde 200) → se pueden bajar paquetes NuGet y el MIP SDK.
  • MIP SDK no está instalado como SDK, pero los assemblies están presentes en C:\Program Files\Milestone\XProtect Event Server: VideoOS.Platform.dll, VideoOS.Platform.Common.dll, VideoOS.Platform.Primitives.dll, VideoOS.Platform.SDK vía NuGet.
  • MilestonePSTools 25.2.61 quedó instalado en el servidor (útil para prototipado y scripts de verificación).
  • .NET 8 + ASP.NET Core Hosting Bundle ya presentes → se puede publicar un servicio .NET 8 sin instalar runtime adicional.

Capacidad de zonas de la cámara

attributes.cgi/eventsources no declara un maxCount de DefinedArea/Line; los índices son abiertos (DefinedArea.#). La cámara de lab tiene 2 DefinedAreas y 1 Line configuradas. Los HandoverIndex van 0..32.

Decisión tomada: soportar 5 zonas y 5 líneas, alineado con el criterio de Hikvision (que usa 6: _0.._5) y para no inflar la lista de tipos. Se usa la numeración nativa de Hanwha, que es 1-based (DefinedAreaID.1...5), a diferencia de Hikvision que es 0-based — así el nombre del tipo coincide con lo que muestra el web de la cámara.