using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Threading; using HikvisionAttendanceService.Data; namespace HikvisionAttendanceService; /// /// Resolves sync targets from the attendance_machine repository without site-scope filtering. /// Source-device selection via is unchanged. /// internal sealed partial class HikvisionAttendanceManager { // Prefer universal paths — AccessControl/capabilities returns 404 on many terminals. private static readonly string[] TargetIsapiProbePaths = { "/ISAPI/System/deviceInfo", "/ISAPI/AccessControl/UserInfo/capabilities?format=json", "/ISAPI/Intelligent/FDLib?format=json", "/ISAPI/AccessControl/capabilities?format=json" }; private bool TryResolveSyncTargetByIp(string requestedIp, out HikvisionAttendanceWindowsService.DeviceConfig? device) { device = null; var wantedIp = (requestedIp ?? "").Trim(); if (wantedIp.Length == 0) { LogTargetResolve(wantedIp, found: false, machineId: "", port: 0, machineType: "", note: "empty IP"); return false; } if (_attendanceMachineRepository == null || !_config.EnableDbIntegration) { device = ResolveDeviceConfigByIp(wantedIp); var found = device != null && !string.IsNullOrWhiteSpace(device.Ip); LogTargetResolve( wantedIp, found, device?.DeviceId ?? "", device?.Port ?? 0, "CONFIG_FALLBACK", found ? "resolved from runtime config (DB integration disabled)" : "Target Device Not Found"); return found; } if (!_attendanceMachineRepository.TryGetMachineByIp(wantedIp, out var row, out var dbErr)) { LogTargetResolve(wantedIp, found: false, machineId: "", port: 0, machineType: "", note: string.IsNullOrWhiteSpace(dbErr) ? "Target Device Not Found" : "Target lookup failed: " + dbErr); _logger.Warn("UserSync: Target Device Not Found. TargetMachineIp=\"" + wantedIp + "\"."); return false; } if (!IsAttendanceMachineActive(row!)) { LogTargetResolve( wantedIp, found: false, row!.MachineId, row.PortNumber, row.MachineType, "Target device exists but machine_status is not active"); _logger.Warn("UserSync: target inactive in DB. TargetMachineIp=\"" + wantedIp + "\" machine_id=" + row.MachineId + " machine_status=\"" + row.MachineStatus + "\"."); return false; } if (!TryBuildDeviceConfigFromAttendanceMachine(row!, out device, out var buildErr)) { LogTargetResolve(wantedIp, found: false, row!.MachineId, row.PortNumber, row.MachineType, buildErr); _logger.Warn("UserSync: target found but could not be built. TargetMachineIp=\"" + wantedIp + "\" reason=\"" + buildErr + "\"."); return false; } LogTargetResolve( wantedIp, found: true, device!.DeviceId, device.Port, row!.MachineType, "resolved from attendance_machine"); return true; } private bool TryResolveSyncTargetByMachineId(string requestedMachineId, out HikvisionAttendanceWindowsService.DeviceConfig? device) { device = null; var wantedId = (requestedMachineId ?? "").Trim(); if (wantedId.Length == 0) { LogTargetResolve("", found: false, machineId: wantedId, port: 0, machineType: "", note: "empty machine_id"); return false; } if (_attendanceMachineRepository == null || !_config.EnableDbIntegration) { device = ResolveDeviceConfig(wantedId); var found = device != null && !string.IsNullOrWhiteSpace(device.Ip); LogTargetResolve( device?.Ip ?? "", found, wantedId, device?.Port ?? 0, "CONFIG_FALLBACK", found ? "resolved from runtime config (DB integration disabled)" : "Target Device Not Found"); return found; } if (!_attendanceMachineRepository.TryGetMachineByMachineId(wantedId, out var row, out var dbErr)) { LogTargetResolve("", found: false, machineId: wantedId, port: 0, machineType: "", note: string.IsNullOrWhiteSpace(dbErr) ? "Target Device Not Found" : "Target lookup failed: " + dbErr); _logger.Warn("UserSync: Target Device Not Found. TargetDeviceId=\"" + wantedId + "\"."); return false; } if (!IsAttendanceMachineActive(row!)) { LogTargetResolve( row!.MachineIp, found: false, row.MachineId, row.PortNumber, row.MachineType, "Target device exists but machine_status is not active"); _logger.Warn("UserSync: target inactive in DB. TargetDeviceId=\"" + wantedId + "\" machine_status=\"" + row.MachineStatus + "\"."); return false; } if (!TryBuildDeviceConfigFromAttendanceMachine(row!, out device, out var buildErr)) { LogTargetResolve(row!.MachineIp, found: false, row.MachineId, row.PortNumber, row.MachineType, buildErr); return false; } LogTargetResolve(device!.Ip, found: true, device.DeviceId, device.Port, row!.MachineType, "resolved from attendance_machine"); return true; } private bool TryBuildDeviceConfigFromAttendanceMachine( AttendanceMachineRow machine, out HikvisionAttendanceWindowsService.DeviceConfig? device, out string error) { device = null; error = ""; if (string.IsNullOrWhiteSpace(machine.MachineIp)) { error = "machine_ip empty in attendance_machine"; return false; } var configDevices = _config.Devices ?? new List(); var credentialFallback = configDevices.FirstOrDefault(d => !string.IsNullOrWhiteSpace(d.Username) && !string.IsNullOrWhiteSpace(d.Password)); var matchingCfg = configDevices.FirstOrDefault(x => string.Equals((x.Ip ?? "").Trim(), machine.MachineIp.Trim(), StringComparison.OrdinalIgnoreCase)); var creds = matchingCfg ?? credentialFallback; if (creds == null || string.IsNullOrWhiteSpace(creds.Username) || string.IsNullOrWhiteSpace(creds.Password)) { error = "no username/password in serviceconfig Devices for target ip=" + machine.MachineIp; return false; } var deviceId = !string.IsNullOrWhiteSpace(machine.MachineId) ? machine.MachineId.Trim() : (!string.IsNullOrWhiteSpace(machine.MachineName) ? machine.MachineName.Trim() : machine.MachineIp.Trim()); var port = machine.PortNumber > 0 ? machine.PortNumber : (creds.Port > 0 ? creds.Port : 8000); device = new HikvisionAttendanceWindowsService.DeviceConfig { DeviceId = deviceId, Ip = machine.MachineIp.Trim(), Port = port, Username = creds.Username, Password = creds.Password, FingerPrintReaderNo = creds.FingerPrintReaderNo, FaceReaderNo = creds.FaceReaderNo, GatewayDoorIndex = creds.GatewayDoorIndex, Model = string.IsNullOrWhiteSpace(machine.MachineName) ? (creds.Model ?? "") : machine.MachineName, SerialNumber = creds.SerialNumber, FirmwareVersion = creds.FirmwareVersion, SubnetMask = creds.SubnetMask, DefaultGateway = creds.DefaultGateway }; return true; } private static bool IsAttendanceMachineActive(AttendanceMachineRow machine) { var status = (machine.MachineStatus ?? "").Trim(); return string.Equals(status, "active", StringComparison.OrdinalIgnoreCase) || status == "1"; } private void LogTargetResolve(string requestedIp, bool found, string machineId, int port, string machineType, string note) { _logger.Ops("[TARGET_RESOLVE]", "requestedIp=" + (requestedIp ?? "") + " found=" + (found ? "true" : "false") + " machineId=" + (machineId ?? "") + " port=" + port + " type=" + (machineType ?? "") + (string.IsNullOrWhiteSpace(note) ? "" : (" note=\"" + note + "\""))); } /// /// ISAPI HTTP connectivity probe (Digest) on the HTTP port — not ICMP ping. /// Tries several endpoints because firmware/models omit some capabilities URLs (404 is not offline). /// private bool TryTestTargetIsapiConnectivity( HikvisionAttendanceWindowsService.DeviceConfig device, out string outcome, out string detail) { outcome = ""; detail = ""; int isapiPort = IsapiPort; var ip = (device.Ip ?? "").Trim(); var requestLabel = "GET ISAPI probe (" + TargetIsapiProbePaths.Length + " paths)"; var tcpProbe = ConnectivityDiagnostics.ProbeBeforeLogin(ip, isapiPort, tcpTimeoutMs: 5000, tryPing: false); if (tcpProbe.TcpChecked && !tcpProbe.TcpOk) { outcome = "Connection Failed"; detail = tcpProbe.FriendlyReason; LogTargetTest(requestLabel, outcome, detail); return false; } try { using var handler = BuildDigestHandler(device); using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }; string lastNonAuthFailure = ""; var sawHttpResponse = false; foreach (var path in TargetIsapiProbePaths) { var url = "http://" + ip + ":" + isapiPort + path; using var response = client.GetAsync(url).GetAwaiter().GetResult(); sawHttpResponse = true; var bodySnippet = ""; try { var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); if (!string.IsNullOrWhiteSpace(body)) bodySnippet = body.Length > 120 ? body.Substring(0, 120) : body; } catch { // ignore body read errors } var pathLabel = "GET " + path; if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) { outcome = "Authentication Failed"; detail = pathLabel + " → HTTP " + (int)response.StatusCode + " " + response.ReasonPhrase; LogTargetTest(pathLabel, outcome, detail); return false; } if (response.IsSuccessStatusCode) { outcome = "OK"; detail = pathLabel + " → HTTP " + (int)response.StatusCode; LogTargetTest(pathLabel, outcome, detail); return true; } lastNonAuthFailure = pathLabel + " → HTTP " + (int)response.StatusCode + " " + response.ReasonPhrase + (string.IsNullOrWhiteSpace(bodySnippet) ? "" : (" body=\"" + bodySnippet + "\"")); LogTargetTest(pathLabel, "probe_miss", lastNonAuthFailure); } // Device answered HTTP but probe URLs are unsupported on this firmware. // Allow sync to proceed; real UserInfo / FaceDataRecord calls will report real errors. if (sawHttpResponse) { outcome = "OK"; detail = "device reachable on HTTP " + isapiPort + "; probe URLs unsupported (last: " + lastNonAuthFailure + ")"; LogTargetTest(requestLabel, outcome, detail); return true; } outcome = "Connection Failed"; detail = string.IsNullOrWhiteSpace(lastNonAuthFailure) ? "No HTTP response from ISAPI probes." : lastNonAuthFailure; LogTargetTest(requestLabel, outcome, detail); return false; } catch (TaskCanceledException ex) { outcome = "Connection Failed"; detail = "ISAPI request timed out: " + ex.Message; LogTargetTest(requestLabel, outcome, detail); return false; } catch (HttpRequestException ex) { outcome = "Connection Failed"; detail = ex.Message; LogTargetTest(requestLabel, outcome, detail); return false; } catch (Exception ex) { outcome = "Connection Failed"; detail = ex.GetType().Name + ": " + ex.Message; LogTargetTest(requestLabel, outcome, detail); return false; } } private void LogTargetTest(string request, string outcome, string detail) { _logger.Ops("[TARGET_TEST]", "request=" + request + " outcome=" + outcome + (string.IsNullOrWhiteSpace(detail) ? "" : (" detail=\"" + detail + "\""))); } private void LogDbToDeviceResult( string employeeNo, string createUser, string faceUpload, string verification, string finalResult) { _logger.Ops("[DB_TO_DEVICE]", "employee=" + employeeNo + " createUser=" + createUser + " faceUpload=" + faceUpload + " verification=" + verification + " finalResult=" + finalResult); } /// /// Resolves configured sync targets from DB (no scope filter), tests ISAPI connectivity, returns ready targets. /// private List CollectTestedSyncTargets( HikvisionAttendanceWindowsService.DeviceConfig? source, bool hasSource, CancellationToken ct) { var targets = new List(); foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty()) { ct.ThrowIfCancellationRequested(); if (!TryResolveSyncTargetByIp(targetIp, out var device) || device == null) continue; if (targets.Any(x => string.Equals(x.Ip, device.Ip, StringComparison.OrdinalIgnoreCase))) continue; if (hasSource && string.Equals((device.Ip ?? "").Trim(), (source!.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) && !_config.EnableTemplateDbToDeviceSync) { _logger.Diag("user_sync", "target skipped — same as source IP and DB->device restore disabled. TargetMachineIp=\"" + targetIp + "\"."); continue; } if (!TryTestTargetIsapiConnectivity(device, out var testOutcome, out var testDetail)) { _logger.Warn("UserSync: target connectivity failed. TargetMachineIp=\"" + targetIp + "\" machine_id=" + device.DeviceId + " outcome=\"" + testOutcome + "\" detail=\"" + testDetail + "\"."); var failTitle = testOutcome == "Authentication Failed" ? "Authentication Failed." : "Connection Failed."; var failLines = new[] { "MACHINE " + (device.DeviceId ?? ""), "", failTitle, "", "Machine IP : " + (device.Ip ?? ""), "ISAPI HTTP Port : " + IsapiPort, "Detail : " + (string.IsNullOrWhiteSpace(testDetail) ? "(none)" : testDetail), "" }; _logger.Biz(BizChannel.UserSync, failLines); _logger.BizSeparator(BizChannel.UserSync); if (_config.EnableInitialDepartmentSync) { _logger.Biz(BizChannel.DepartmentalSync, failLines); _logger.BizSeparator(BizChannel.DepartmentalSync); } if (_config.EnableTemplateDbToDeviceSync) { _logger.Biz(BizChannel.Template, "Target Device :", "", (device.Ip ?? ""), "", failTitle, "", "Detail : " + (string.IsNullOrWhiteSpace(testDetail) ? "(none)" : testDetail), ""); _logger.BizSeparator(BizChannel.Template); } continue; } targets.Add(device); } foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty()) { ct.ThrowIfCancellationRequested(); if (!TryResolveSyncTargetByMachineId(tid, out var device) || device == null) continue; if (targets.Any(x => string.Equals(x.Ip, device.Ip, StringComparison.OrdinalIgnoreCase) || DeviceIdentity.CanonicalLookupKey(x.DeviceId) == DeviceIdentity.CanonicalLookupKey(device.DeviceId))) continue; if (hasSource && DeviceIdentity.CanonicalLookupKey(device.DeviceId) == DeviceIdentity.CanonicalLookupKey(source!.DeviceId)) { if (!_config.EnableTemplateDbToDeviceSync) { _logger.Warn("UserSync: target skipped — same as source. deviceId=\"" + device.DeviceId + "\"."); continue; } } if (!TryTestTargetIsapiConnectivity(device, out var testOutcome, out var testDetail)) { _logger.Warn("UserSync: target connectivity failed. TargetDeviceId=\"" + tid + "\" machine_id=" + device.DeviceId + " outcome=\"" + testOutcome + "\" detail=\"" + testDetail + "\"."); continue; } targets.Add(device); } return targets; } }