param([string]$Server = 'https://remote.segolene.co.za', [switch]$Enroll, [switch]$AllowLocalDevelopment, [switch]$Configure, [switch]$Install, [switch]$Background) $ErrorActionPreference = 'Stop' if ([Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') { throw 'Run using powershell.exe -STA -File .\SegoleneRemote.ps1' } $uri = [Uri]$Server if ($uri.Scheme -ne 'https' -and -not ($AllowLocalDevelopment -and $uri.IsLoopback)) { throw 'HTTPS is required.' } $Server = $Server.TrimEnd('/') Add-Type -AssemblyName System.Windows.Forms, System.Drawing, System.Security $agentMutex = New-Object Threading.Mutex($false, 'Local\SegoleneRemoteAgent') if (-not $agentMutex.WaitOne(0)) { [Windows.Forms.MessageBox]::Show('The Segolene Remote host agent is already running for this Windows user.') | Out-Null; exit 0 } Add-Type @' using System; using System.Runtime.InteropServices; public class RemoteInput { [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); [DllImport("user32.dll")] public static extern void mouse_event(uint flags,uint dx,uint dy,uint data,UIntPtr extra); } '@ $folder = Join-Path $env:LOCALAPPDATA 'SegoleneRemote' New-Item -ItemType Directory -Force -Path $folder | Out-Null $credentialFile = Join-Path $folder 'device.xml' function Call-Api($Route, $Payload, $Auth = $true) { $headers = @{} if ($Auth) { $headers.Authorization = 'Bearer ' + $script:key } Invoke-RestMethod -Method Post -Uri ($Server + '/api/agent/' + $Route) -Headers $headers -ContentType 'application/json' -Body (ConvertTo-Json -InputObject $Payload -Compress -Depth 5) -TimeoutSec 5 } function Ask-Enrollment { $dialog=New-Object Windows.Forms.Form; $dialog.Text='Enroll this PC';$dialog.Width=540;$dialog.Height=200;$dialog.StartPosition='CenterScreen' $label=New-Object Windows.Forms.Label;$label.Text='Sign in to your approved account, select Add a computer, and paste its enrollment code.';$label.SetBounds(15,15,490,40) $inputBox=New-Object Windows.Forms.TextBox;$inputBox.SetBounds(15,65,490,25);$inputBox.UseSystemPasswordChar=$true $ok=New-Object Windows.Forms.Button;$ok.Text='Enroll PC';$ok.SetBounds(380,105,125,32);$ok.DialogResult='OK';$dialog.AcceptButton=$ok $dialog.Controls.AddRange(@($label,$inputBox,$ok)) try {if($dialog.ShowDialog() -ne 'OK'){throw 'Enrollment cancelled'};return $inputBox.Text.Trim()} finally {$dialog.Dispose()} } function Save-HostCredential { [PSCustomObject]@{Server=$Server;Key=$credential.Key;Unattended=[bool]$script:hostUnattended;Control=[bool]$script:hostControl;Files=[bool]$script:hostFiles;ShareRoot=$script:shareRoot;Number=$script:machineNumber;Configured=$true} | Export-Clixml -LiteralPath $credentialFile } function Show-HostSettings { $info=Call-Api 'info' @{};$script:machineNumber=$info.number $dialog=New-Object Windows.Forms.Form;$dialog.Text=('Segolene Remote - PC '+$script:machineNumber);$dialog.Width=620;$dialog.Height=410;$dialog.StartPosition='CenterScreen' $unattendedBox=New-Object Windows.Forms.CheckBox;$unattendedBox.Text='Allow unattended access while I am signed in to Windows';$unattendedBox.Checked=[bool]$script:hostUnattended;$unattendedBox.SetBounds(20,20,570,25) $passwordLabel=New-Object Windows.Forms.Label;$passwordLabel.Text='PC access password (16+ characters). Leave blank to keep the existing password.';$passwordLabel.SetBounds(20,60,570,25) $passwordBox=New-Object Windows.Forms.TextBox;$passwordBox.UseSystemPasswordChar=$true;$passwordBox.SetBounds(20,90,560,26) $controlBox=New-Object Windows.Forms.CheckBox;$controlBox.Text='Allow keyboard and mouse control';$controlBox.Checked=[bool]$script:hostControl;$controlBox.SetBounds(20,135,560,25) $filesBox=New-Object Windows.Forms.CheckBox;$filesBox.Text='Allow file transfers inside the folder below (100 MB per file)';$filesBox.Checked=[bool]$script:hostFiles;$filesBox.SetBounds(20,175,560,25) $folderBox=New-Object Windows.Forms.TextBox;$folderBox.Text=$script:shareRoot;$folderBox.SetBounds(20,210,440,26);$folderBox.ReadOnly=$true $browse=New-Object Windows.Forms.Button;$browse.Text='Choose folder';$browse.SetBounds(470,207,110,32) $browse.Add_Click({$picker=New-Object Windows.Forms.FolderBrowserDialog;$picker.Description='Choose the folder this PC may transfer files from and into';if($picker.ShowDialog() -eq 'OK'){$folderBox.Text=$picker.SelectedPath};$picker.Dispose()}) $note=New-Object Windows.Forms.Label;$note.Text='Your approved account and platform owner can access this PC. This agent starts after Windows sign-in. Lock-screen, sign-out and UAC control are not supported.';$note.SetBounds(20,255,560,48) $ok=New-Object Windows.Forms.Button;$ok.Text='Save settings';$ok.SetBounds(440,315,140,34);$ok.DialogResult='OK';$dialog.AcceptButton=$ok $dialog.Controls.AddRange(@($unattendedBox,$passwordLabel,$passwordBox,$controlBox,$filesBox,$folderBox,$browse,$note,$ok)) try { if($dialog.ShowDialog() -ne 'OK'){return} $settings=@{unattended=$unattendedBox.Checked;control=$controlBox.Checked;files=$filesBox.Checked} if($passwordBox.Text){$settings.password=$passwordBox.Text} if($unattendedBox.Checked -and -not $info.unattended -and $passwordBox.Text.Length -lt 16){throw 'Choose a PC password of at least 16 characters'} $rootPath=[IO.Path]::GetFullPath($folderBox.Text) if($filesBox.Checked){New-Item -ItemType Directory -Force -Path $rootPath | Out-Null;if((Get-Item -LiteralPath $rootPath).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Transfer folder cannot be a junction or symbolic link'}} $saved=Call-Api 'settings' $settings;$passwordBox.Clear();$settings.password=$null $script:hostUnattended=[bool]$saved.unattended;$script:hostControl=[bool]$saved.control;$script:hostFiles=[bool]$saved.files;$script:shareRoot=$rootPath Save-HostCredential } finally {$dialog.Dispose()} } function Resolve-SharePath([string]$Relative) { if(-not $script:hostFiles){throw 'Local file transfer is disabled'} $base=[IO.Path]::GetFullPath($script:shareRoot).TrimEnd('\') if($Relative -match '(^/|\\|:|[\x00-\x1f*?"<>|])' -or $Relative.Split('/') -contains '..' -or $Relative.Split('/') -contains '.'){throw 'Invalid transfer path'} $target=[IO.Path]::GetFullPath((Join-Path $base ($Relative.Replace('/','\')))) if($target -ne $base -and -not $target.StartsWith($base+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'Transfer path is outside the approved folder'} $check=$target while($check -and $check.Length -ge $base.Length){if(Test-Path -LiteralPath $check){if((Get-Item -LiteralPath $check -Force).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Junctions and symbolic links are not allowed'}};if($check -eq $base){break};$check=[IO.Path]::GetDirectoryName($check)} return $target } function Process-FileJobs($Jobs) { foreach($job in $Jobs){ $tempPath=$null try { $target=Resolve-SharePath ([string]$job.path) if($job.kind -eq 'list'){ $entries=@(Get-ChildItem -LiteralPath $target -Force | Where-Object {-not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -and $_.Name -notlike '.segolene-*.part'} | Select-Object -First 500 | ForEach-Object {@{name=$_.Name;directory=$_.PSIsContainer;size=$(if($_.PSIsContainer){0}else{$_.Length})}}) Call-Api 'files/result' @{transfer=$job.id;entries=$entries} | Out-Null } elseif($job.kind -eq 'download'){ $stream=[IO.File]::Open($target,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) $hash=[Security.Cryptography.SHA256]::Create() try { if($stream.Length -gt 104857600){throw 'File exceeds 100 MB limit'} Call-Api 'files/result' @{transfer=$job.id;begin=$true;size=$stream.Length} | Out-Null $buffer=New-Object byte[] 1048576;$offset=0 while(($read=$stream.Read($buffer,0,$buffer.Length)) -gt 0){ [Windows.Forms.Application]::DoEvents();if($script:stopSharing){throw 'Sharing stopped locally'} Call-Api 'files/push' @{transfer=$job.id;offset=$offset;data=[Convert]::ToBase64String($buffer,0,$read)} | Out-Null $hash.TransformBlock($buffer,0,$read,$null,0) | Out-Null;$offset+=$read } $hash.TransformFinalBlock((New-Object byte[] 0),0,0) | Out-Null Call-Api 'files/push' @{transfer=$job.id;done=$true;checksum=([BitConverter]::ToString($hash.Hash).Replace('-','').ToLowerInvariant())} | Out-Null } finally {$stream.Dispose();$hash.Dispose()} } elseif($job.kind -eq 'upload'){ if(Test-Path -LiteralPath $target){throw 'A file with this name already exists. Rename the upload; existing files are never overwritten.'} if(-not (Test-Path -LiteralPath ([IO.Path]::GetDirectoryName($target)) -PathType Container)){throw 'Destination folder does not exist'} $tempPath=Join-Path ([IO.Path]::GetDirectoryName($target)) ('.segolene-'+$job.id+'.part') $stream=[IO.File]::Open($tempPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);$hash=[Security.Cryptography.SHA256]::Create() try { $offset=0;$index=0 while($offset -lt $job.size){ [Windows.Forms.Application]::DoEvents();if($script:stopSharing){throw 'Sharing stopped locally'} $chunk=Call-Api 'files/pull' @{transfer=$job.id;index=$index};$bytes=[Convert]::FromBase64String($chunk.data) if($offset+$bytes.Length -gt $job.size -or -not $bytes.Length){throw 'Invalid upload size'} $stream.Write($bytes,0,$bytes.Length);$hash.TransformBlock($bytes,0,$bytes.Length,$null,0) | Out-Null;$offset+=$bytes.Length;$index++ } $hash.TransformFinalBlock((New-Object byte[] 0),0,0) | Out-Null;$checksum=[BitConverter]::ToString($hash.Hash).Replace('-','').ToLowerInvariant() if($checksum -ne $job.checksum){throw 'File checksum mismatch'} } finally {$stream.Dispose();$hash.Dispose()} Resolve-SharePath ([string]$job.path) | Out-Null [IO.File]::Move($tempPath,$target);$tempPath=$null try {Set-Content -LiteralPath $target -Stream 'Zone.Identifier' -Value "[ZoneTransfer]`r`nZoneId=3"} catch {} Call-Api 'files/result' @{transfer=$job.id;done=$true;checksum=$checksum} | Out-Null } } catch { try {Call-Api 'files/result' @{transfer=$job.id;error=$_.Exception.Message} | Out-Null} catch {} } finally {if($tempPath -and (Test-Path -LiteralPath $tempPath)){Remove-Item -LiteralPath $tempPath -Force}} } } function Install-HostStartup { $installed=Join-Path $folder 'SegoleneRemote.ps1' if([IO.Path]::GetFullPath($PSCommandPath) -ne [IO.Path]::GetFullPath($installed)){Copy-Item -LiteralPath $PSCommandPath -Destination $installed -Force} $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value & icacls.exe $folder /inheritance:r /grant:r ('*'+$sid+':(OI)(CI)F') '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null if($LASTEXITCODE -ne 0){throw 'Cannot protect local agent configuration'} $command='powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File "'+$installed+'" -Server "'+$Server+'" -Background' New-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'SegoleneRemote' -Value $command -PropertyType String -Force | Out-Null } if ($Enroll -or -not (Test-Path -LiteralPath $credentialFile)) { $code = Ask-Enrollment $result = Call-Api 'enroll' @{ ticket = $code } $false # Export-Clixml encrypts SecureString with Windows DPAPI, tied to this Windows user. [PSCustomObject]@{ Server = $Server; Key = (ConvertTo-SecureString $result.key -AsPlainText -Force) } | Export-Clixml -LiteralPath $credentialFile $code = $null; $result = $null } $credential = Import-Clixml -LiteralPath $credentialFile if ($credential.Server -ne $Server) { throw 'Saved device belongs to a different server. Use -Enroll to replace it.' } $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($credential.Key) try { $script:key = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } $script:hostUnattended=[bool]$credential.Unattended $script:hostControl=($credential.Control -ne $false) $script:hostFiles=[bool]$credential.Files $script:shareRoot=if($credential.ShareRoot){$credential.ShareRoot}else{Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'Segolene Remote Files'} $script:machineNumber=$credential.Number if($Configure -or -not $credential.Configured){Show-HostSettings} if($Install){Install-HostStartup} $script:quitHost=$false;$script:configureRequested=$false $tray=New-Object Windows.Forms.NotifyIcon;$tray.Icon=[Drawing.SystemIcons]::Application;$tray.Text=('Segolene Remote - '+$script:machineNumber);$tray.Visible=$true $menu=New-Object Windows.Forms.ContextMenuStrip $numberItem=$menu.Items.Add('PC '+$script:machineNumber);$numberItem.Enabled=$false $settingsItem=$menu.Items.Add('Host settings');$settingsItem.Add_Click({$script:configureRequested=$true;$script:stopSharing=$true}) $stopItem=$menu.Items.Add('Stop sharing');$stopItem.Add_Click({$script:stopSharing=$true}) $quitItem=$menu.Items.Add('Go offline and exit');$quitItem.Add_Click({$script:quitHost=$true;$script:stopSharing=$true}) $tray.ContextMenuStrip=$menu Write-Host ('Segolene Remote PC '+$script:machineNumber+' is running. Use its tray icon for settings or to go offline.') function Send-Frame($Id) { $bounds = [Windows.Forms.Screen]::PrimaryScreen.Bounds $bitmap = New-Object Drawing.Bitmap($bounds.Width, $bounds.Height) $graphics = [Drawing.Graphics]::FromImage($bitmap) $scaled = $null; $stream = New-Object IO.MemoryStream try { $graphics.CopyFromScreen($bounds.Location, [Drawing.Point]::Empty, $bounds.Size) $width = [Math]::Min(1440, $bounds.Width) $height = [int]($bounds.Height * $width / $bounds.Width) $scaled = New-Object Drawing.Bitmap($bitmap, $width, $height) $encoder = [Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object MimeType -eq 'image/jpeg' $parameters = New-Object Drawing.Imaging.EncoderParameters(1) $parameters.Param[0] = New-Object Drawing.Imaging.EncoderParameter([Drawing.Imaging.Encoder]::Quality, [long]45) try { $scaled.Save($stream, $encoder, $parameters) } finally { $parameters.Dispose() } $reply = Call-Api 'frame' @{ id = $Id; image = [Convert]::ToBase64String($stream.ToArray()) } Process-FileJobs $reply.jobs foreach ($command in $reply.commands) { if(-not $script:hostControl){continue} if ($script:stopSharing) { break } switch ($command.type) { 'click' { [RemoteInput]::SetCursorPos(($bounds.X + [int]($command.x * ($bounds.Width - 1))), ($bounds.Y + [int]($command.y * ($bounds.Height - 1)))) | Out-Null; [RemoteInput]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [RemoteInput]::mouse_event(4,0,0,0,[UIntPtr]::Zero) } 'rightClick' { [RemoteInput]::SetCursorPos(($bounds.X + [int]($command.x * ($bounds.Width - 1))), ($bounds.Y + [int]($command.y * ($bounds.Height - 1)))) | Out-Null; [RemoteInput]::mouse_event(8,0,0,0,[UIntPtr]::Zero); [RemoteInput]::mouse_event(16,0,0,0,[UIntPtr]::Zero) } 'key' { if ($command.key -in @('ENTER','TAB','ESC','BACKSPACE','UP','DOWN','LEFT','RIGHT')) { [Windows.Forms.SendKeys]::SendWait('{' + $command.key + '}') } } 'text' { foreach ($character in $command.text.ToCharArray()) { if ($character -in @('+','^','%','~','(',')','[',']','{','}')) { [Windows.Forms.SendKeys]::SendWait('{' + $character + '}') } elseif (-not [char]::IsControl($character)) { [Windows.Forms.SendKeys]::SendWait([string]$character) } } } } } } finally { $graphics.Dispose(); $bitmap.Dispose(); if ($scaled) { $scaled.Dispose() }; $stream.Dispose() } } try { while (-not $script:quitHost) { [Windows.Forms.Application]::DoEvents() if($script:configureRequested){$script:configureRequested=$false;try{Show-HostSettings}catch{[Windows.Forms.MessageBox]::Show($_.Exception.Message) | Out-Null}} try {$poll = Call-Api 'poll' @{}} catch {Write-Warning 'Server unavailable or PC revoked; sharing is off. Retrying in five seconds.';for($retry=0;$retry -lt 50 -and -not $script:quitHost;$retry++){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 100};continue} foreach ($request in $poll.requests) { if($request.unattended){$approved=$script:hostUnattended}else{ $choice=[Windows.Forms.MessageBox]::Show('Your approved Segolene account is requesting access to this PC. Approve only if expected. Local permissions apply.','Segolene Remote - access request','YesNo','Warning','Button2') $approved=$choice -eq [Windows.Forms.DialogResult]::Yes } Call-Api 'approve' @{ id = $request.id; approved = $approved } | Out-Null if (-not $approved) { continue } $script:stopSharing = $false $form = New-Object Windows.Forms.Form $form.StartPosition='Manual';$form.Location=New-Object Drawing.Point(10,([Windows.Forms.Screen]::PrimaryScreen.WorkingArea.Bottom-150));$form.Text = 'Segolene Remote - SCREEN SHARING ACTIVE'; $form.Width = 480; $form.Height = 130; $form.TopMost = $true $button = New-Object Windows.Forms.Button $button.Text = 'STOP SHARING AND REMOTE CONTROL'; $button.Dock = 'Fill'; $button.BackColor = [Drawing.Color]::MistyRose $button.Add_Click({ $script:stopSharing = $true }); $form.Add_FormClosing({ $script:stopSharing = $true }) $form.Controls.Add($button); $form.Show() $deadline = [DateTime]::UtcNow.AddMinutes(10) try { while (-not $script:stopSharing -and [DateTime]::UtcNow -lt $deadline) { [Windows.Forms.Application]::DoEvents() if ($script:stopSharing) { break } Send-Frame $request.id [Windows.Forms.Application]::DoEvents() Start-Sleep -Milliseconds 350 } } catch { Write-Host ('Session ended: ' + $_.Exception.Message) } finally { $script:stopSharing = $true; $form.Dispose() try { Call-Api 'stop' @{ id = $request.id } | Out-Null } catch { Write-Host 'Session already ended or server unreachable.' } } } Start-Sleep -Seconds 2 } } catch { Write-Warning ('Agent stopped. Screen sharing is off. ' + $_.Exception.Message) } finally { $script:key = $null; if($tray){$tray.Visible=$false;$tray.Dispose()};$menu.Dispose();$agentMutex.ReleaseMutex(); $agentMutex.Dispose() }