<# KlokkoHR - Instalador del agente de asistencia para Windows. Uso normal (la plataforma genera el instalador con un token de despliegue dentro): powershell -NoProfile -ExecutionPolicy Bypass -File KlokkoHR-Setup.ps1 -DeployToken Desinstalar (conserva la configuracion para reutilizar la credencial del equipo): powershell -NoProfile -ExecutionPolicy Bypass -File KlokkoHR-Setup.ps1 -Uninstall #> param( [string]$DeployToken = '', # Existe solo para explicar el cambio: los instaladores descargados antes de que # se cerrara la clave de licencia pasan -TenantToken. No se acepta, pero un error # claro es mejor que un fallo de parametro no encontrado. [string]$TenantToken = '', [string]$ApiUrl = 'https://klokkohr.base44.app/functions/ingestAgentEvent', [string]$DeviceCredential = '', [string]$InstallDir = 'C:\Program Files\KlokkoHR', [switch]$Uninstall ) $InstallerVersion = '1.1.0' $TaskName = 'KlokkoHRAgent' $ErrorActionPreference = 'Stop' function Write-Step($Message) { Write-Host ('[KlokkoHR] ' + $Message) } if (-not $DeployToken -and $TenantToken) { Write-Step 'Este instalador es de una version anterior: la clave de licencia ya no autoriza instalar equipos.' Write-Step 'Genera un token de despliegue en la plataforma (Dispositivos -> Despliegue) y descarga el instalador de nuevo.' exit 1 } # ── Administrador: se escribe en Program Files y se registra una tarea del sistema $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $isAdmin = (New-Object Security.Principal.WindowsPrincipal($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { if (-not $PSCommandPath) { Write-Step 'Guarda este archivo y ejecutalo como administrador: powershell -ExecutionPolicy Bypass -File KlokkoHR-Setup.ps1' exit 1 } Write-Step 'Se necesitan permisos de administrador. Autoriza la ventana que se abrira.' $argList = @('-NoProfile','-ExecutionPolicy','Bypass','-File', ('"' + $PSCommandPath + '"'), '-DeployToken', $DeployToken, '-ApiUrl', $ApiUrl, '-InstallDir', $InstallDir) if ($DeviceCredential) { $argList += @('-DeviceCredential', $DeviceCredential) } if ($Uninstall) { $argList += '-Uninstall' } Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs -Wait exit } if ($Uninstall) { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Write-Step 'Agente detenido y tarea programada eliminada.' Write-Step ('La carpeta ' + $InstallDir + ' se conserva para que una reinstalacion reutilice la credencial del equipo.') exit } if (-not $DeployToken) { Write-Step 'Falta el token de despliegue. Generalo en la plataforma (Dispositivos -> Despliegue) y ejecuta el instalador de esa pantalla.' exit 1 } # ── Credencial propia del equipo ─────────────────────────────────────────────── $cfgPath = Join-Path $InstallDir 'config.json' if (-not $DeviceCredential -and (Test-Path $cfgPath)) { try { $DeviceCredential = [string](Get-Content $cfgPath -Raw | ConvertFrom-Json).device_credential } catch { $DeviceCredential = '' } } # La credencial guardada se comprueba contra la plataforma ANTES de reutilizarla: # si el equipo se dio de baja en Dispositivos, esa credencial ya no abre nada y # reinstalar con ella dejaba al agente reportando en vano. En ese caso se pide una # nueva con el token de despliegue de este instalador. if ($DeviceCredential) { $probe = @{ hostname = $env:COMPUTERNAME event_type = 'heartbeat' agent_version = $InstallerVersion timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') device_credential = $DeviceCredential } | ConvertTo-Json -Compress try { Invoke-RestMethod -Uri $ApiUrl -Method Post -ContentType 'application/json' -Body $probe -TimeoutSec 25 -UseBasicParsing | Out-Null Write-Step 'La credencial guardada en este equipo sigue vigente.' } catch { Write-Step 'La credencial guardada en este equipo ya no es valida: se solicitara una nueva.' $DeviceCredential = '' } } if (-not $DeviceCredential) { Write-Step 'Emitiendo la credencial propia de este equipo...' $enrollUrl = $ApiUrl -replace 'ingestAgentEvent\s*$', 'enrollAgentDevice' $osCaption = '' try { $osCaption = [string](Get-CimInstance -ClassName Win32_OperatingSystem).Caption } catch { $osCaption = '' } $enrollBody = @{ deploy_token = $DeployToken; device_name = $env:COMPUTERNAME; agent_version = $InstallerVersion; os_version = $osCaption } | ConvertTo-Json try { $enroll = Invoke-RestMethod -Uri $enrollUrl -Method Post -ContentType 'application/json' -Body $enrollBody -TimeoutSec 30 -UseBasicParsing if ($enroll.device_credential) { $DeviceCredential = [string]$enroll.device_credential } } catch { $detail = '' try { $detail = [string]$_.ErrorDetails.Message } catch { $detail = '' } if (-not $detail) { $detail = [string]$_.Exception.Message } Write-Step ('No se pudo emitir la credencial: ' + $detail) } } if (-not $DeviceCredential) { Write-Step 'Este equipo no tiene credencial propia, asi que la clave de la empresa no se acepta en su nombre.' Write-Step 'Pide a Recursos Humanos que reemita la credencial del equipo en la plataforma (Dispositivos) y vuelve a ejecutar este instalador.' exit 1 } # ── Agente y configuracion ──────────────────────────────────────────────────── Write-Step ('Instalando en ' + $InstallDir) New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $agentPath = Join-Path $InstallDir 'KlokkoHRAgent.ps1' $agentScript = @' # KlokkoHR - Agente de asistencia para Windows. # # Lo instala KlokkoHR-Setup.ps1 y el Programador de tareas lo ejecuta como SYSTEM, # sin ventana y en cada arranque del equipo. # # Reporta al servidor los cambios de estado de la sesion (inicio, bloqueo, # desbloqueo y cierre) con la credencial propia de ESTE equipo. $ErrorActionPreference = 'SilentlyContinue' $AgentVersion = '1.1.0' $PollSeconds = 20 $MaxQueueLines = 2000 $InstallDir = Split-Path -Parent $PSCommandPath $ConfigPath = Join-Path $InstallDir 'config.json' $StatePath = Join-Path $InstallDir 'state.txt' $QueuePath = Join-Path $InstallDir 'pending.jsonl' $LogPath = Join-Path $InstallDir 'agent.log' try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { } function Write-AgentLog($Message) { $line = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') + 'Z ' + $Message Add-Content -Path $LogPath -Value $line if ((Test-Path $LogPath) -and ((Get-Item $LogPath).Length -gt 1048576)) { $tail = @(Get-Content $LogPath -Tail 400) Set-Content -Path $LogPath -Value $tail } } function Send-Payload($Json, $Label) { try { Invoke-RestMethod -Uri $Config.api_endpoint -Method Post -ContentType 'application/json' -Body $Json -TimeoutSec 25 -UseBasicParsing | Out-Null Write-AgentLog ('reportado: ' + $Label) return $true } catch { Add-Content -Path $QueuePath -Value $Json Write-AgentLog ('sin conexion, en cola: ' + $Label) return $false } } function Send-AgentEvent($EventType) { $payload = @{ hostname = $Config.device_id event_type = $EventType timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') agent_version = $AgentVersion } if ($Config.device_credential) { $payload['device_credential'] = $Config.device_credential } return Send-Payload ($payload | ConvertTo-Json -Compress) $EventType } function Send-QueuedEvents { if (-not (Test-Path $QueuePath)) { return } $queued = @(Get-Content $QueuePath) if ($queued.Count -eq 0) { Remove-Item $QueuePath -Force; return } $pending = @() $sent = 0 foreach ($line in $queued) { if ($null -eq $line -or $line.Trim().Length -eq 0) { continue } try { Invoke-RestMethod -Uri $Config.api_endpoint -Method Post -ContentType 'application/json' -Body $line -TimeoutSec 25 -UseBasicParsing | Out-Null $sent = $sent + 1 } catch { $pending += $line } } if ($pending.Count -gt $MaxQueueLines) { $pending = @($pending[($pending.Count - $MaxQueueLines)..($pending.Count - 1)]) } if ($pending.Count -gt 0) { Set-Content -Path $QueuePath -Value $pending } else { Remove-Item $QueuePath -Force } if ($sent -gt 0) { Write-AgentLog ('reenviados desde la cola: ' + $sent) } } # Estado de la sesion del equipo: 'off' sin usuario, 'locked' con la sesion # bloqueada (LogonUI activo) y 'active' con el escritorio en uso. function Get-SessionState { if (@(Get-Process -Name explorer).Count -eq 0) { return 'off' } if (@(Get-Process -Name LogonUI).Count -gt 0) { return 'locked' } return 'active' } $Config = $null try { $Config = Get-Content $ConfigPath -Raw | ConvertFrom-Json } catch { $Config = $null } if ($null -eq $Config -or -not $Config.api_endpoint) { Write-AgentLog 'config.json ausente o incompleto: el agente no puede reportar' exit 1 } # Latido inicial: confirma en la plataforma que el agente esta vivo aunque la # sesion del equipo no cambie de estado. Sin esto, un equipo recien instalado o # reinstalado quedaba como "No instalado" hasta que el usuario bloqueara la # pantalla o cerrara sesion. Se repite cada 30 minutos dentro del ciclo. Send-AgentEvent 'heartbeat' | Out-Null $LastHeartbeat = Get-Date # Estado previo: al reiniciarse el agente (o el equipo) se ADOPTA el estado actual # sin volver a anunciar una sesion que ya estaba abierta. Solo una instalacion # nueva durante la jornada registra el inicio de sesion. $previous = '' if (Test-Path $StatePath) { $previous = (Get-Content $StatePath -Raw).Trim() } $lastState = Get-SessionState Set-Content -Path $StatePath -Value $lastState Write-AgentLog ('agente iniciado. estado: ' + $lastState + ' / previo: ' + $previous) # Con el escritorio en uso al arrancar se anuncia la conexion del dia aunque ya # exista estado previo: sin eso, un equipo que nunca se bloquea quedaba sin # inicio de jornada y su asistencia no se registraba. if ($lastState -eq 'active') { Send-AgentEvent 'session_start' | Out-Null } while ($true) { $state = Get-SessionState if ($state -ne $lastState) { if ($state -eq 'active') { if ($lastState -eq 'locked') { Send-AgentEvent 'screen_unlock' } else { Send-AgentEvent 'session_start' } } elseif ($state -eq 'locked') { Send-AgentEvent 'screen_lock' } else { Send-AgentEvent 'session_end' } Set-Content -Path $StatePath -Value $state $lastState = $state } if (((Get-Date) - $LastHeartbeat).TotalMinutes -ge 30) { if (Send-AgentEvent 'heartbeat') { $LastHeartbeat = Get-Date } } Send-QueuedEvents Start-Sleep -Seconds $PollSeconds } '@ Set-Content -Path $agentPath -Value $agentScript -Encoding UTF8 $config = @{ api_endpoint = $ApiUrl device_id = $env:COMPUTERNAME device_credential = $DeviceCredential agent_version = $InstallerVersion installed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } $config | ConvertTo-Json | Set-Content -Path $cfgPath -Encoding UTF8 # ── Tarea del sistema: arranque + revision periodica (vigilante del agente) ─── $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $agentPath + '"') $triggers = @(New-ScheduledTaskTrigger -AtStartup) try { # Duracion acotada a proposito: [TimeSpan]::MaxValue escribe P99999999DT23H59M59S # y Windows rechaza la tarea completa con "valor fuera de intervalo" (0x80041318), # que abortaba la instalacion entera en algunos equipos. $triggers += New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 1095) } catch { Write-Step 'Aviso: no se pudo programar la revision periodica; el agente arrancara con el equipo.' } $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest try { Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Settings $settings -Principal $principal -Force -ErrorAction Stop | Out-Null } catch { # Si Windows no acepta el disparador periodico, la tarea de arranque se registra igual # y el agente sigue funcionando sin dejar la instalacion a medias. Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger (New-ScheduledTaskTrigger -AtStartup) -Settings $settings -Principal $principal -Force | Out-Null Write-Step 'Aviso: la tarea quedo solo para el arranque del equipo.' } Start-ScheduledTask -TaskName $TaskName Start-Sleep -Seconds 3 $task = Get-ScheduledTask -TaskName $TaskName Write-Step ('Agente instalado. Estado de la tarea: ' + $task.State) Write-Step 'Este equipo ya reporta su asistencia con su credencial propia.'