# ClassMan Agent - classroom management agent # Poll-based: no inbound ports. SYSTEM scheduled task "CoreSync" runs this every 5 min + at startup. # Auto-update: compares $VERSION with server, downloads new script, replaces self, exits. $ErrorActionPreference = "Continue" $VERSION = 7 $Global:SERVER = "https://classman.dbt-kbtc.app" # --- config --------------------------------------------------------------- $DIR = "C:\ProgramData\CoreSync" $TOKEN_FILE = Join-Path $DIR "id" $LOCK_EXE = Join-Path $DIR "lock.exe" $WALLPAPER = Join-Path $DIR "wallpaper.png" $LOG = Join-Path $DIR "agent.log" $MUTEX_NAME = "Global\CoreSyncMut1" function Log($m) { try { "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $m" | Out-File $LOG -Append -Encoding utf8 } catch {} } # single instance guard $mutex = New-Object System.Threading.Mutex($false, $MUTEX_NAME) if (-not $mutex.WaitOne(0)) { exit 0 } # TLS 1.2 for old Win10 builds [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 if (-not (Test-Path $DIR)) { New-Item -ItemType Directory -Path $DIR -Force | Out-Null } function Get-Token { if (Test-Path $TOKEN_FILE) { return (Get-Content $TOKEN_FILE -Raw).Trim() } return $null } function Save-Token($t) { [IO.File]::WriteAllText($TOKEN_FILE, $t) # hide from casual eyes (Get-Item $TOKEN_FILE -Force).Attributes = "Hidden" } function Get-OS { $cv = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue if ($cv) { return "$($cv.Caption) (build $($cv.BuildNumber))" } return [Environment]::OSVersion.VersionString } function Get-Disk { try { $d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'" -ErrorAction Stop return @{ free = [math]::Round($d.FreeSpace / 1GB, 1); total = [math]::Round($d.Size / 1GB, 1) } } catch { return $null } } function Register-Agent { $body = @{ hostname = $env:COMPUTERNAME; os = Get-OS } | ConvertTo-Json -Compress $r = Invoke-RestMethod -Uri "$SERVER/api/agent/register" -Method Post -Body $body -ContentType "application/json" -TimeoutSec 30 if ($r.token) { Save-Token $r.token; return $r.token } return $null } function Invoke-Poll($token) { $h = @{ Authorization = "Bearer $token" } return Invoke-RestMethod -Uri "$SERVER/api/agent/poll" -Headers $h -TimeoutSec 30 } function Send-Report($token, $obj) { $h = @{ Authorization = "Bearer $token" } $body = $obj | ConvertTo-Json -Depth 5 -Compress try { Invoke-RestMethod -Uri "$SERVER/api/agent/report" -Method Post -Headers $h -Body $body -ContentType "application/json" -TimeoutSec 30 | Out-Null } catch { Log "report failed: $($_.Exception.Message)" } } # --- job executors -------------------------------------------------------- function Invoke-Winget($pkg, $mode) { if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { return "ERROR: winget not installed" } if ($mode -eq "install") { $out = winget install --id $pkg --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-String } else { $out = winget uninstall --id $pkg --silent 2>&1 | Out-String } return $out.Trim().Substring(0, [math]::Min(2000, $out.Trim().Length)) } function Invoke-Registry($p) { try { if ($p.action -eq "delete") { Remove-ItemProperty -Path $p.key -Name $p.name -ErrorAction Stop return "deleted $($p.key)\$($p.name)" } $kind = $p.kind; if (-not $kind) { $kind = "String" } New-Item -Path $p.key -Force | Out-Null Set-ItemProperty -Path $p.key -Name $p.name -Value $p.value -Type $kind -Force return "set $($p.key)\$($p.name)" } catch { return "ERROR: $($_.Exception.Message)" } } function Get-ProfilesDir($sub) { # returns list of C:\Users\\ for every real profile $list = @() Get-ChildItem "C:\Users" -Directory -ErrorAction SilentlyContinue | ForEach-Object { # skip system profiles if ($_.Name -in @("Public", "Default", "Default User", "All Users", "desktop.ini")) { return } $p = Join-Path $_.FullName $sub if (Test-Path $p) { $list += $p } } return $list } function Invoke-Cleanup($p) { $results = @() foreach ($x in $p.paths) { try { if ($x -eq "Recycle") { Clear-RecycleBin -Force -ErrorAction SilentlyContinue $results += "Recycle: cleared" } elseif ($x -eq "Temp") { Get-ChildItem "$env:windir\Temp", "$env:TEMP" -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $results += "Temp: cleaned" } elseif ($x -eq "Custom" -or $x.StartsWith("Custom:")) { $path = $x.Substring(7) if ($path -and (Test-Path $path)) { Get-ChildItem $path -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $results += "Custom ${path}: cleaned" } } else { # Downloads / Desktop for every profile foreach ($d in (Get-ProfilesDir $x)) { Get-ChildItem $d -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $results += "${x} ($d): cleaned" } } } catch { $results += "${x}: ERROR $($_.Exception.Message)" } } return ($results -join "; ") } function Invoke-Screenshot($token) { try { # SYSTEM has no screen — delegate capture into the interactive user session via temp task $user = (Get-CimInstance Win32_ComputerSystem).UserName if (-not $user) { return "ERROR: no interactive user session" } $shotPs1 = Join-Path $DIR "shot.ps1" $shot = Join-Path $DIR "shot.jpg" $code = @' Add-Type -AssemblyName System.Windows.Forms,System.Drawing # DPI-aware: without this Windows lies about coordinates on scaled displays (150%) and crops right/bottom Add-Type -TypeDefinition "using System.Runtime.InteropServices; public class DPI { [DllImport(`"user32.dll`")] public static extern bool SetProcessDPIAware(); }" [DPI]::SetProcessDPIAware() | Out-Null # VirtualScreen = every monitor combined (multi-monitor safe) $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen $b = New-Object System.Drawing.Bitmap($vs.Width, $vs.Height) $g = [System.Drawing.Graphics]::FromImage($b) $g.CopyFromScreen($vs.X, $vs.Y, 0, 0, $b.Size) $ep = New-Object System.Drawing.Imaging.EncoderParameters(1) $ep.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality,[long]70) $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" } $b.Save("C:\ProgramData\CoreSync\shot.jpg", $codec, $ep) '@ [IO.File]::WriteAllText($shotPs1, $code) Remove-Item $shot -Force -ErrorAction SilentlyContinue $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$shotPs1`"" $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive Register-ScheduledTask -TaskName "coresync-shot" -Action $action -Principal $principal -Force | Out-Null Start-ScheduledTask -TaskName "coresync-shot" for ($i = 0; $i -lt 15; $i++) { Start-Sleep -Seconds 1 if (Test-Path $shot) { break } } Unregister-ScheduledTask -TaskName "coresync-shot" -Confirm:$false -ErrorAction SilentlyContinue if (-not (Test-Path $shot)) { return "ERROR: capture timed out (screen locked?)" } $bytes = [IO.File]::ReadAllBytes($shot) Remove-Item $shot -Force -ErrorAction SilentlyContinue if ($bytes.Length -lt 100) { return "ERROR: empty capture" } $h = @{ Authorization = "Bearer $token"; "content-type" = "image/jpeg" } Invoke-RestMethod -Uri "$SERVER/api/agent/screenshot" -Method Post -Headers $h -Body $bytes -TimeoutSec 60 | Out-Null return "uploaded $($bytes.Length) bytes" } catch { return "ERROR: $($_.Exception.Message)" } } # --- user-session helper (SYSTEM has no desktop) --------------------------- function Start-UserScript($taskName, $ps1Path) { # run a ps1 in the interactive user session via temp scheduled task $user = (Get-CimInstance Win32_ComputerSystem).UserName if (-not $user) { return $false } $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$ps1Path`"" $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null Start-ScheduledTask -TaskName $taskName return $true } function Invoke-Wallpaper($token) { try { # download as SYSTEM, then apply in USER session (SPI_SETDESKWALLPAPER writes HKCU of caller) Invoke-WebRequest -Uri "$SERVER/api/agent/wallpaper?token=$token" -OutFile $WALLPAPER -TimeoutSec 60 | Out-Null $wpPs1 = Join-Path $DIR "wp.ps1" $done = Join-Path $DIR "wp.done" $code = @' Add-Type -TypeDefinition "using System.Runtime.InteropServices; public class WP { [DllImport(`"user32.dll`", CharSet=CharSet.Unicode)] public static extern int SystemParametersInfo(int u,int p,string v,int f); }" [WP]::SystemParametersInfo(20,0,"C:\ProgramData\CoreSync\wallpaper.png",3) Set-Content -Path "C:\ProgramData\CoreSync\wp.done" -Value "1" '@ [IO.File]::WriteAllText($wpPs1, $code) Remove-Item $done -Force -ErrorAction SilentlyContinue if (-not (Start-UserScript "coresync-wp" $wpPs1)) { return "ERROR: no user session" } for ($i = 0; $i -lt 10; $i++) { Start-Sleep -Milliseconds 500; if (Test-Path $done) { break } } Unregister-ScheduledTask -TaskName "coresync-wp" -Confirm:$false -ErrorAction SilentlyContinue if (-not (Test-Path $done)) { return "ERROR: wallpaper apply timed out" } Remove-Item $done -Force -ErrorAction SilentlyContinue return "wallpaper set (user session)" } catch { return "ERROR: $($_.Exception.Message)" } } function Start-Lock { # real fullscreen lock overlay in the USER session (winform + low-level keyboard hook) if (Get-ScheduledTask -TaskName "coresync-lock" -ErrorAction SilentlyContinue) { return "already locked" } $lockPs1 = Join-Path $DIR "lockform.ps1" $code = @' Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing # low-level keyboard hook: swallow everything except we allow Alt+Ctrl nothing Add-Type -TypeDefinition @" using System; using System.Diagnostics; using System.Runtime.InteropServices; using System.Windows.Input; public class KBHook : IDisposable { [StructLayout(LayoutKind.Sequential)] class KBDLLHOOKSTRUCT { public uint vkCode; public uint scanCode; public uint flags; public uint time; public IntPtr extra; } delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll")] static extern IntPtr SetWindowsHookEx(int id, HookProc cb, IntPtr hMod, uint tid); [DllImport("user32.dll")] static extern bool UnhookWindowsHookEx(IntPtr hh); [DllImport("kernel32.dll")] static extern IntPtr GetModuleHandle(string name); const int WH_KEYBOARD_LL = 13; HookProc proc; IntPtr hh; public KBHook() { proc = (n,w,l) => { if (n>=0) return (IntPtr)1; return CallNext(n,w,l); }; hh = SetWindowsHookEx(WH_KEYBOARD_LL, proc, GetModuleHandle(null), 0); } [DllImport("user32.dll")] static extern IntPtr CallNextHookEx(IntPtr hh, int n, IntPtr w, IntPtr l); IntPtr CallNext(int n, IntPtr w, IntPtr l) { return CallNextHookEx(hh, n, w, l); } public void Dispose() { if (hh != IntPtr.Zero) UnhookWindowsHookEx(hh); } } "@ $flag = "C:\ProgramData\CoreSync\unlock.flag" if (Test-Path $flag) { Remove-Item $flag -Force } $f = New-Object System.Windows.Forms.Form $f.FormBorderStyle = "None" $f.StartPosition = "Manual" $f.TopMost = $true $f.ShowInTaskbar = $false $f.BackColor = [System.Drawing.Color]::FromArgb(10,10,14) $b = [System.Windows.Forms.Screen]::AllScreens $minx = ($b | ForEach-Object { $_.Bounds.X } | Measure-Object -Minimum).Minimum $miny = ($b | ForEach-Object { $_.Bounds.Y } | Measure-Object -Minimum).Minimum $maxx = ($b | ForEach-Object { $_.Bounds.X + $_.Bounds.Width } | Measure-Object -Maximum).Maximum $maxy = ($b | ForEach-Object { $_.Bounds.Y + $_.Bounds.Height } | Measure-Object -Maximum).Maximum $f.Location = New-Object System.Drawing.Point($minx,$miny) $f.Size = New-Object System.Drawing.Size(($maxx-$minx),($maxy-$miny)) $f.Cursor = [System.Windows.Forms.Cursors]::No $paint = { $g = $this.CreateGraphics() $g.Clear([System.Drawing.Color]::FromArgb(10,10,14)) $br = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(148,163,184)) $big = New-Object System.Drawing.Font("Segoe UI", 44, [System.Drawing.FontStyle]::Bold) $small = New-Object System.Drawing.Font("Segoe UI", 18) $fmt = New-Object System.Drawing.StringFormat $fmt.Alignment = [System.Drawing.StringAlignment]::Center $cx = $this.ClientSize.Width / 2 $g.DrawString("CLASSMAN", $big, $br, (New-Object System.Drawing.PointF($cx, ($this.ClientSize.Height/2 - 80))), $fmt) $br2 = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(100,116,139)) $g.DrawString("Lock screen by teacher. Wait for instructions.", $small, $br2, (New-Object System.Drawing.PointF($cx, ($this.ClientSize.Height/2 + 10))), $fmt) $g.Dispose() } $f.Add_Paint($paint) $hook = New-Object KBHook $timer = New-Object System.Windows.Forms.Timer $timer.Interval = 1000 $timer.Add_Tick({ if (Test-Path $flag) { $hook.Dispose(); $timer.Stop(); $f.Close() } }) $timer.Start() [System.Windows.Forms.Application]::Run($f) '@ [IO.File]::WriteAllText($lockPs1, $code) if (-not (Start-UserScript "coresync-lock" $lockPs1)) { return "ERROR: no user session" } return "locked" } function Stop-Lock { # signal the overlay to close Set-Content -Path (Join-Path $DIR "unlock.flag") -Value "1" # also remove the task so it does not restart Unregister-ScheduledTask -TaskName "coresync-lock" -Confirm:$false -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 Remove-Item (Join-Path $DIR "unlock.flag") -Force -ErrorAction SilentlyContinue return "unlocked" } function Invoke-Job($j, $token) { $p = $j.payload switch ($j.type) { "install" { return Invoke-Winget $p.package "install" } "uninstall" { return Invoke-Winget $p.package "uninstall" } "registry" { return Invoke-Registry $p } "cleanup" { return Invoke-Cleanup $p } "shutdown" { shutdown /s /t 30 /f; return "shutting down in 30s" } "restart" { shutdown /r /t 30 /f; return "restarting in 30s" } "monitoroff" { (Get-WmiObject Win32_Desktop).foreach{ & powercfg /change monitor-timeout-ac 1 }; return "monitor timeout set" } "screenshot" { return Invoke-Screenshot $token } "wallpaper" { return Invoke-Wallpaper $token } "lock" { return Start-Lock } "unlock" { return Stop-Lock } default { return "ERROR: unknown type $($j.type)" } } } # --- main loop ------------------------------------------------------------ # heartbeat data $os = Get-OS while ($true) { try { $token = Get-Token if (-not $token) { $token = Register-Agent; Log "registered" } if ($token) { $poll = Invoke-Poll $token # process watch (kill blocked apps) $killed = @() if ($poll.blocked -and $poll.blocked.Count -gt 0) { $procs = Get-Process -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name -Unique foreach ($b in $poll.blocked) { $bn = [IO.Path]::GetFileNameWithoutExtension($b) if ($procs -contains $bn) { Stop-Process -Name $bn -Force -ErrorAction SilentlyContinue $killed += $bn } } } # jobs foreach ($j in $poll.jobs) { Log "job $($j.type)" $res = Invoke-Job $j $token $st = if ($res -like "ERROR*") { "error" } else { "done" } Send-Report $token @{ jobId = $j.id; status = $st; result = "$res" } } # wallpaper revision check if ($null -ne $poll.wallpaperRev) { $lastWp = -1 $wpFlag = Join-Path $DIR "wp.rev" if (Test-Path $wpFlag) { $lastWp = [int](Get-Content $wpFlag -Raw).Trim() } if ([int]$poll.wallpaperRev -gt $lastWp) { $r = Invoke-Wallpaper $token Log "wallpaper rev $($poll.wallpaperRev): $r" Set-Content -Path $wpFlag -Value "$($poll.wallpaperRev)" } } # heartbeat $disk = Get-Disk $hb = @{ os = $os; agentVersion = $VERSION; wallpaperRev = $poll.wallpaperRev diskFreeGB = if ($disk) { $disk.free } else { $null } diskTotalGB = if ($disk) { $disk.total } else { $null } } Send-Report $token @{ heartbeat = $hb; killed = $killed } # auto-update if ([int]$poll.agentVersion -gt $VERSION) { Log "updating to v$($poll.agentVersion)" try { $tmp = Join-Path $DIR "agent.new" Invoke-WebRequest -Uri "$SERVER/api/agent/script" -OutFile $tmp -TimeoutSec 60 if ((Get-Item $tmp).Length -gt 1KB) { # parse-check the new script BEFORE replacing — a broken update must not kill the agent $parseErrors = $null [System.Management.Automation.Language.Parser]::ParseFile($tmp, [ref]$null, [ref]$parseErrors) | Out-Null if ($parseErrors -and $parseErrors.Count -gt 0) { Log "update rejected: $($parseErrors[0].Message)" Remove-Item $tmp -Force -ErrorAction SilentlyContinue } else { $self = Join-Path $DIR "agent.ps1" Copy-Item $self "$self.bak" -Force Move-Item $tmp $self -Force Log "updated -> exit" exit 0 # task re-launches with new version } } } catch { Log "update failed: $($_.Exception.Message)" } } } } catch { Log "loop error: $($_.Exception.Message)" } Start-Sleep -Seconds 30 }