# win8-bridge-agent-marker # ============================================================================ # win8-bridge agent - runs on the Windows PC as SYSTEM (scheduled task "win8-bridge" -> launcher.ps1 -> this) # Long-polls https://win8.ashavolian.com/api/poll for jobs from the Mac Mini, runs them, posts the results back. # Outbound HTTPS only. PowerShell 3.0 / .NET 4.5 compatible (Windows 8). ASCII only. # Job kinds: ps (PowerShell), cmd (cmd.exe), put (download file from Mac), get (upload file to Mac), update (refresh me) # ============================================================================ $ErrorActionPreference = 'Continue' $AgentVersion = '2.0' $Base = 'https://win8.ashavolian.com' $Dir = $env:WIN8_BRIDGE_DIR if (-not $Dir) { $Dir = Join-Path $env:ProgramData 'win8-bridge' } $PsExe = $env:WIN8_BRIDGE_PSEXE if (-not $PsExe) { $PsExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' } $CmdExe = $null if ($env:SystemRoot) { $CmdExe = Join-Path $env:SystemRoot 'System32\cmd.exe' } $IsWin = ($env:OS -eq 'Windows_NT') $JobDir = Join-Path $Dir 'jobs' $LogFile = Join-Path $Dir 'agent.log' $TokenFile = Join-Path $Dir 'token' if (-not (Test-Path $JobDir)) { $null = New-Item -ItemType Directory -Path $JobDir -Force } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::Expect100Continue = $false function Log($m) { $line = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + ' ' + $m try { if ((Test-Path $LogFile) -and ((Get-Item $LogFile).Length -gt 5MB)) { Move-Item $LogFile ($LogFile + '.1') -Force } Add-Content -Path $LogFile -Value $line -Encoding ASCII } catch { } } $Token = (Get-Content $TokenFile -ErrorAction Stop | Select-Object -First 1).Trim() $osInfo = $null if ($IsWin) { try { $osInfo = Get-WmiObject Win32_OperatingSystem } catch { } } $info = @{ hostname = $env:COMPUTERNAME; agent = $AgentVersion; ps = ('' + $PSVersionTable.PSVersion); pid = $PID } if ($osInfo) { $info['os'] = $osInfo.Caption.Trim(); $info['osver'] = $osInfo.Version } $InfoJson = $info | ConvertTo-Json -Compress function New-Client { $wc = New-Object Net.WebClient $wc.Headers['Authorization'] = 'Bearer ' + $Token $wc.Headers['X-Agent-Info'] = $InfoJson $wc.Headers['User-Agent'] = 'win8-bridge-agent/' + $AgentVersion $wc.Encoding = [Text.Encoding]::UTF8 return $wc } function Err-Text($e) { $m = $e.Exception.Message if ($e.Exception.InnerException) { $m = $m + ' / ' + $e.Exception.InnerException.Message } return $m } function Clip($s, $max) { if ($null -eq $s) { return '' } if ($s.Length -le $max) { return $s } return $s.Substring(0, $max) + "`r`n...[truncated " + ($s.Length - $max) + ' chars]' } function Start-Child($exe, $argString, $timeoutSec, $outFile, $errFile) { $sp = @{ FilePath = $exe; ArgumentList = $argString; RedirectStandardOutput = $outFile; RedirectStandardError = $errFile; PassThru = $true } if ($IsWin) { $sp['WindowStyle'] = 'Hidden' } $p = Start-Process @sp $timedOut = $false if (-not $p.WaitForExit([int]$timeoutSec * 1000)) { $timedOut = $true try { $p.Kill() } catch { } Start-Sleep -Milliseconds 500 } $code = 0 try { $code = $p.ExitCode } catch { } if ($timedOut) { $code = 124 } return $code } function Read-Output($file, $enc) { if (-not (Test-Path $file)) { return '' } try { return [IO.File]::ReadAllText($file, $enc) } catch { return '' } } function Run-Job($job) { $started = Get-Date $r = @{ exit = 0; stdout = ''; stderr = ''; kind = ('' + $job.kind); started = $started.ToString('s'); agent = $AgentVersion } $timeout = 120 if ($job.timeout) { $timeout = [int]$job.timeout } $exitAfter = $false try { switch ($job.kind) { 'ps' { $script = Join-Path $JobDir ($job.id + '.ps1') $outF = Join-Path $JobDir ($job.id + '.out'); $errF = Join-Path $JobDir ($job.id + '.err') $body = "[Console]::OutputEncoding = [Text.Encoding]::UTF8`r`n`$ErrorActionPreference = 'Continue'`r`ntry {`r`n" + $job.cmd + "`r`n} catch { `$_ | Out-String | Write-Error; exit 1 }`r`n" [IO.File]::WriteAllText($script, $body, [Text.Encoding]::UTF8) $r['exit'] = Start-Child $PsExe ('-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $script + '"') $timeout $outF $errF $r['stdout'] = Clip (Read-Output $outF ([Text.Encoding]::UTF8)) 1000000 $r['stderr'] = Clip (Read-Output $errF ([Text.Encoding]::UTF8)) 200000 if ($r['exit'] -eq 124) { $r['stderr'] += "`r`n[agent] killed after " + $timeout + 's timeout' } Remove-Item $script, $outF, $errF -Force -ErrorAction SilentlyContinue } 'cmd' { if (-not $CmdExe) { throw 'cmd.exe not available here' } $script = Join-Path $JobDir ($job.id + '.cmd') $outF = Join-Path $JobDir ($job.id + '.out'); $errF = Join-Path $JobDir ($job.id + '.err') [IO.File]::WriteAllText($script, "@echo off`r`n" + $job.cmd + "`r`n", [Text.Encoding]::Default) $r['exit'] = Start-Child $CmdExe ('/d /c "' + $script + '"') $timeout $outF $errF $enc = [Console]::OutputEncoding $r['stdout'] = Clip (Read-Output $outF $enc) 1000000 $r['stderr'] = Clip (Read-Output $errF $enc) 200000 if ($r['exit'] -eq 124) { $r['stderr'] += "`r`n[agent] killed after " + $timeout + 's timeout' } Remove-Item $script, $outF, $errF -Force -ErrorAction SilentlyContinue } 'put' { $dest = '' + $job.path $parent = Split-Path $dest -Parent if ($parent -and -not (Test-Path $parent)) { $null = New-Item -ItemType Directory -Path $parent -Force } $wc = New-Client $wc.DownloadFile($Base + '/api/blob/' + $job.blob, $dest) $r['stdout'] = 'wrote ' + (Get-Item $dest).Length + ' bytes to ' + $dest } 'get' { $src = '' + $job.path if (-not (Test-Path $src)) { throw ('no such file: ' + $src) } $bytes = [IO.File]::ReadAllBytes($src) $wc = New-Client $wc.Headers['Content-Type'] = 'application/octet-stream' $null = $wc.UploadData($Base + '/api/blob/' + $job.id, 'PUT', $bytes) $r['stdout'] = 'uploaded ' + $bytes.Length + ' bytes from ' + $src } 'update' { $wc = New-Client $new = $wc.DownloadString($Base + '/agent.ps1') if ($new -notlike '*win8-bridge-agent-marker*') { throw 'downloaded agent.ps1 failed validation' } [IO.File]::WriteAllText((Join-Path $Dir 'agent.ps1'), $new, [Text.Encoding]::ASCII) $r['stdout'] = 'agent.ps1 replaced; agent restarting' $exitAfter = $true } default { throw ('unknown job kind: ' + $job.kind) } } } catch { $r['exit'] = 1 $r['stderr'] = ('' + $r['stderr'] + "`r`n[agent] " + (Err-Text $_)).Trim() } $r['finished'] = (Get-Date).ToString('s') $r['seconds'] = [int]((Get-Date) - $started).TotalSeconds Post-Result $job.id $r Log ('job ' + $job.id + ' (' + $job.kind + ') exit=' + $r['exit'] + ' in ' + $r['seconds'] + 's') if ($exitAfter) { Log 'exiting for update'; exit 0 } } function Post-Result($jobId, $r) { $json = $r | ConvertTo-Json -Compress -Depth 3 for ($i = 0; $i -lt 6; $i++) { try { $wc = New-Client $wc.Headers['Content-Type'] = 'application/json' $null = $wc.UploadString($Base + '/api/result/' + $jobId, 'POST', $json) return } catch { Log ('result post failed (try ' + ($i + 1) + '): ' + (Err-Text $_)) Start-Sleep -Seconds (5 * ($i + 1)) } } } # housekeeping: drop job scratch files older than a day try { Get-ChildItem $JobDir -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } | Remove-Item -Force -ErrorAction SilentlyContinue } catch { } Log ('agent ' + $AgentVersion + ' starting, base ' + $Base + ', host ' + $env:COMPUTERNAME) $backoff = 5 while ($true) { try { $wc = New-Client $resp = $wc.DownloadString($Base + '/api/poll?wait=25') $backoff = 5 if ($resp -and $resp.Trim() -ne '{}') { $job = ConvertFrom-Json $resp if ($job.id) { Run-Job $job } } } catch { Log ('poll error: ' + (Err-Text $_)) Start-Sleep -Seconds $backoff if ($backoff -lt 120) { $backoff = $backoff * 2 } } }