diff --git a/remote-execution/Export-PCList.ps1 b/remote-execution/Export-PCList.ps1 new file mode 100644 index 0000000..3eba573 --- /dev/null +++ b/remote-execution/Export-PCList.ps1 @@ -0,0 +1,223 @@ +<# +.SYNOPSIS + Queries ShopDB API and exports a computer list for Invoke-RemoteMaintenance.ps1. + +.DESCRIPTION + Pulls shopfloor PC data from the ShopDB API and writes a text file + (one hostname per line) compatible with: + Invoke-RemoteMaintenance.ps1 -ComputerListFile "shopfloor-pcs.txt" + + Can filter by PC type, business unit, or export all. + +.PARAMETER PcType + Filter by PC type. Omit for all types. + +.PARAMETER BusinessUnit + Filter by business unit. Omit for all BUs. + +.PARAMETER OutputFile + Output file path (default: shopfloor-pcs.txt in script directory). + +.PARAMETER ApiUrl + ShopDB API URL. + +.PARAMETER IncludeDetails + Add IP, PC type, and business unit as comments in the output. + +.EXAMPLE + .\Export-PCList.ps1 + # Export all shopfloor PCs + +.EXAMPLE + .\Export-PCList.ps1 -PcType Dashboard + # Export only Dashboard PCs + +.EXAMPLE + .\Export-PCList.ps1 -BusinessUnit Blisk -OutputFile "blisk-pcs.txt" + +.EXAMPLE + .\Export-PCList.ps1 -PcType Dashboard,Shopfloor -BusinessUnit Blisk,HPT + # Multiple filters (OR logic within each, AND between type/BU) +#> + +[CmdletBinding()] +param( + [ValidateSet('Standard','Engineer','Shopfloor','CMM','Wax / Trace','Keyence', + 'Genspect','Heat Treat','Inspection','Dashboard','Lobby Display','Uncategorized')] + [string[]]$PcType, + + [ValidateSet('TBD','Blisk','HPT','Spools','Inspection','Venture','Turn/Burn','DT')] + [string[]]$BusinessUnit, + + [string]$OutputFile, + + [string]$ApiUrl = "https://tsgwp00525.rd.ds.ge.com/shopdb/api.asp", + + [switch]$IncludeDetails +) + +# --------------------------------------------------------------------------- +# Lookup tables (match IDs in ShopDB) +# --------------------------------------------------------------------------- +$PcTypeLookup = @{ + 'Standard' = 1; 'Engineer' = 2; 'Shopfloor' = 3; 'Uncategorized' = 4; + 'CMM' = 5; 'Wax / Trace' = 6; 'Keyence' = 7; 'Genspect' = 8; + 'Heat Treat' = 9; 'Inspection' = 10; 'Dashboard' = 11; 'Lobby Display' = 12 +} + +$PcTypeReverse = @{} +$PcTypeLookup.GetEnumerator() | ForEach-Object { $PcTypeReverse[$_.Value] = $_.Key } + +$BusinessUnitLookup = @{ + 'TBD' = 1; 'Blisk' = 2; 'HPT' = 3; 'Spools' = 4; + 'Inspection' = 5; 'Venture' = 6; 'Turn/Burn' = 7; 'DT' = 8 +} + +$BusinessUnitReverse = @{} +$BusinessUnitLookup.GetEnumerator() | ForEach-Object { $BusinessUnitReverse[$_.Value] = $_.Key } + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +function Write-Status { + param([string]$Message, [string]$Level = "INFO") + $timestamp = Get-Date -Format "HH:mm:ss" + $color = switch ($Level) { + "OK" { "Green" } + "WARN" { "Yellow" } + "ERROR" { "Red" } + default { "Cyan" } + } + Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color +} + +function Get-ShopfloorPCsFromApi { + param( + [string]$Url, + [int]$PcTypeId = 0, + [int]$BusinessUnitId = 0 + ) + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + $queryParams = "action=getShopfloorPCs" + if ($PcTypeId -gt 0) { + $queryParams += "&pctypeid=$PcTypeId" + } + if ($BusinessUnitId -gt 0) { + $queryParams += "&businessunitid=$BusinessUnitId" + } + + $fullUrl = "$Url`?$queryParams" + $webClient = New-Object System.Net.WebClient + $json = $webClient.DownloadString($fullUrl) + $response = $json | ConvertFrom-Json + + if ($response.success -and $response.data) { + return $response.data + } + return @() +} + +# --------------------------------------------------------------------------- +# Default output path +# --------------------------------------------------------------------------- +if (-not $OutputFile) { + $OutputFile = Join-Path $PSScriptRoot "shopfloor-pcs.txt" +} + +# --------------------------------------------------------------------------- +# Query API +# --------------------------------------------------------------------------- +Write-Status "Querying ShopDB API..." + +try { + $allPCs = Get-ShopfloorPCsFromApi -Url $ApiUrl +} catch { + Write-Status "Failed to query API: $_" -Level "ERROR" + exit 1 +} + +if ($allPCs.Count -eq 0) { + Write-Status "API returned no PCs." -Level "ERROR" + exit 1 +} + +Write-Status "API returned $($allPCs.Count) total PCs" -Level "OK" + +# --------------------------------------------------------------------------- +# Filter +# --------------------------------------------------------------------------- +$filtered = $allPCs + +if ($PcType) { + $typeIds = $PcType | ForEach-Object { $PcTypeLookup[$_] } + $filtered = $filtered | Where-Object { $_.pctypeid -in $typeIds } + Write-Status "Filtered to PC types: $($PcType -join ', ') -> $($filtered.Count) PCs" +} + +if ($BusinessUnit) { + $buIds = $BusinessUnit | ForEach-Object { $BusinessUnitLookup[$_] } + $filtered = $filtered | Where-Object { $_.businessunitid -in $buIds } + Write-Status "Filtered to business units: $($BusinessUnit -join ', ') -> $($filtered.Count) PCs" +} + +$filtered = @($filtered | Where-Object { $_.hostname }) + +if ($filtered.Count -eq 0) { + Write-Status "No PCs match the selected filters." -Level "WARN" + exit 0 +} + +# Sort by hostname +$filtered = $filtered | Sort-Object hostname + +# --------------------------------------------------------------------------- +# Write output +# --------------------------------------------------------------------------- +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add("# Shopfloor PC List - Generated $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") +$lines.Add("# Source: ShopDB API") + +if ($PcType) { + $lines.Add("# PC Type filter: $($PcType -join ', ')") +} +if ($BusinessUnit) { + $lines.Add("# Business Unit filter: $($BusinessUnit -join ', ')") +} + +$lines.Add("# Total: $($filtered.Count) PCs") +$lines.Add("# Usage: .\Invoke-RemoteMaintenance.ps1 -ComputerListFile `"$OutputFile`" -Task Reboot") +$lines.Add("") + +foreach ($pc in $filtered) { + if ($IncludeDetails) { + $typeName = $PcTypeReverse[[int]$pc.pctypeid] + $buName = $BusinessUnitReverse[[int]$pc.businessunitid] + $ip = if ($pc.ipaddress) { $pc.ipaddress } else { "no IP" } + $lines.Add("$($pc.hostname) # $ip | $typeName | $buName") + } else { + $lines.Add($pc.hostname) + } +} + +$lines | Out-File -FilePath $OutputFile -Encoding UTF8 + +Write-Status "Wrote $($filtered.Count) PCs to: $OutputFile" -Level "OK" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== PC LIST ===" -ForegroundColor White + +# Group by type for summary +$byType = $filtered | Group-Object pctypeid | Sort-Object Name +foreach ($group in $byType) { + $typeName = $PcTypeReverse[[int]$group.Name] + if (-not $typeName) { $typeName = "Unknown ($($group.Name))" } + Write-Host " $($typeName): $($group.Count)" -ForegroundColor Gray +} + +Write-Host "" +Write-Host "Output file: $OutputFile" -ForegroundColor Green +Write-Host "Next step: .\Invoke-RemoteMaintenance.ps1 -ComputerListFile `"$OutputFile`" -Task Reboot" -ForegroundColor Yellow diff --git a/remote-execution/Find-ShopfloorPCs.ps1 b/remote-execution/Find-ShopfloorPCs.ps1 new file mode 100644 index 0000000..0f426f1 --- /dev/null +++ b/remote-execution/Find-ShopfloorPCs.ps1 @@ -0,0 +1,266 @@ +<# +.SYNOPSIS + Scans a subnet for live hosts and tests WinRM connectivity to build a + usable computer list for Invoke-RemoteMaintenance.ps1. + +.DESCRIPTION + 1. Pings all IPs in the specified subnet to find responsive hosts. + 2. Tests WinRM (TCP 5985) on each responsive host. + 3. Attempts to resolve hostnames via reverse DNS. + 4. Outputs a text file (one host per line) compatible with + Invoke-RemoteMaintenance.ps1 -ComputerListFile. + +.PARAMETER Subnet + CIDR notation subnet to scan (default: 10.134.48.0/23). + +.PARAMETER OutputFile + Path for the generated computer list (default: shopfloor-pcs.txt in script dir). + +.PARAMETER PingTimeout + Ping timeout in milliseconds (default: 500). + +.PARAMETER WinRMPort + WinRM port to test (default: 5985). + +.PARAMETER WinRMTimeout + TCP connection timeout in milliseconds for WinRM test (default: 1000). + +.PARAMETER ThrottleLimit + Max concurrent ping/test jobs (default: 50). + +.PARAMETER SkipWinRM + Only do ping sweep, skip WinRM test. + +.EXAMPLE + .\Find-ShopfloorPCs.ps1 + # Scans 10.134.48.0/23, outputs shopfloor-pcs.txt + +.EXAMPLE + .\Find-ShopfloorPCs.ps1 -Subnet "10.134.48.0/23" -OutputFile "C:\temp\pcs.txt" + +.EXAMPLE + .\Find-ShopfloorPCs.ps1 -SkipWinRM + # Ping sweep only, no WinRM check +#> + +[CmdletBinding()] +param( + [string]$Subnet = "10.134.48.0/23", + + [string]$OutputFile, + + [int]$PingTimeout = 500, + + [int]$WinRMPort = 5985, + + [int]$WinRMTimeout = 1000, + + [int]$ThrottleLimit = 50, + + [switch]$SkipWinRM +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-Status { + param([string]$Message, [string]$Level = "INFO") + $timestamp = Get-Date -Format "HH:mm:ss" + $color = switch ($Level) { + "OK" { "Green" } + "WARN" { "Yellow" } + "ERROR" { "Red" } + "SKIP" { "DarkGray"} + default { "Cyan" } + } + Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color +} + +function ConvertFrom-CIDR { + <# Returns an array of IP address strings for the given CIDR block. + Excludes network and broadcast addresses. #> + param([string]$CIDR) + + $parts = $CIDR -split '/' + $networkIP = [System.Net.IPAddress]::Parse($parts[0]) + $prefixLen = [int]$parts[1] + + $ipBytes = $networkIP.GetAddressBytes() + # .NET gives bytes in network order (big-endian) + [Array]::Reverse($ipBytes) + $ipUint = [BitConverter]::ToUInt32($ipBytes, 0) + + $hostBits = 32 - $prefixLen + $numAddresses = [math]::Pow(2, $hostBits) + $networkAddr = $ipUint -band ([uint32]::MaxValue -shl $hostBits) + + $ips = [System.Collections.Generic.List[string]]::new() + # Skip .0 (network) and last (broadcast) + for ($i = 1; $i -lt ($numAddresses - 1); $i++) { + $addr = $networkAddr + $i + $bytes = [BitConverter]::GetBytes([uint32]$addr) + [Array]::Reverse($bytes) + $ips.Add(("{0}.{1}.{2}.{3}" -f $bytes[0], $bytes[1], $bytes[2], $bytes[3])) + } + return $ips +} + +# --------------------------------------------------------------------------- +# Default output path +# --------------------------------------------------------------------------- +if (-not $OutputFile) { + $OutputFile = Join-Path $PSScriptRoot "shopfloor-pcs.txt" +} + +# --------------------------------------------------------------------------- +# Phase 1: Generate IP list +# --------------------------------------------------------------------------- +Write-Status "Generating IP list for $Subnet" +$allIPs = ConvertFrom-CIDR -CIDR $Subnet +Write-Status "$($allIPs.Count) host addresses to scan" + +# --------------------------------------------------------------------------- +# Phase 2: Ping sweep (parallel) +# --------------------------------------------------------------------------- +Write-Status "Starting ping sweep (timeout: ${PingTimeout}ms, threads: $ThrottleLimit)..." + +$pingResults = $allIPs | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel { + $ip = $_ + $ping = New-Object System.Net.NetworkInformation.Ping + try { + $reply = $ping.Send($ip, $using:PingTimeout) + if ($reply.Status -eq 'Success') { + [PSCustomObject]@{ + IP = $ip + Alive = $true + RoundTrip = $reply.RoundtripTime + } + } + } catch { + # Silently skip unreachable hosts + } finally { + $ping.Dispose() + } +} + +$aliveHosts = @($pingResults | Where-Object { $_.Alive }) +Write-Status "$($aliveHosts.Count) hosts responded to ping" -Level "OK" + +if ($aliveHosts.Count -eq 0) { + Write-Status "No hosts found. Check subnet and network connectivity." -Level "ERROR" + exit 1 +} + +# --------------------------------------------------------------------------- +# Phase 3: WinRM port test (parallel) +# --------------------------------------------------------------------------- +if ($SkipWinRM) { + Write-Status "Skipping WinRM test (-SkipWinRM)" -Level "SKIP" + $winrmHosts = $aliveHosts +} else { + Write-Status "Testing WinRM (port $WinRMPort) on $($aliveHosts.Count) hosts..." + + $winrmResults = $aliveHosts | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel { + $ip = $_.IP + $roundTrip = $_.RoundTrip + $port = $using:WinRMPort + $timeout = $using:WinRMTimeout + + $tcp = New-Object System.Net.Sockets.TcpClient + try { + $task = $tcp.ConnectAsync($ip, $port) + $connected = $task.Wait($timeout) + if ($connected -and $tcp.Connected) { + [PSCustomObject]@{ + IP = $ip + WinRM = $true + RoundTrip = $roundTrip + } + } + } catch { + # Port closed or filtered + } finally { + $tcp.Dispose() + } + } + + $winrmHosts = @($winrmResults | Where-Object { $_.WinRM }) + $noWinRM = $aliveHosts.Count - $winrmHosts.Count + Write-Status "$($winrmHosts.Count) hosts have WinRM open, $noWinRM alive but no WinRM" -Level "OK" +} + +if ($winrmHosts.Count -eq 0) { + Write-Status "No hosts with WinRM found." -Level "ERROR" + exit 1 +} + +# --------------------------------------------------------------------------- +# Phase 4: Reverse DNS resolution +# --------------------------------------------------------------------------- +Write-Status "Resolving hostnames via reverse DNS..." + +$resolvedHosts = $winrmHosts | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel { + $ip = $_.IP + $roundTrip = $_.RoundTrip + $hostname = $null + try { + $entry = [System.Net.Dns]::GetHostEntry($ip) + if ($entry.HostName -and $entry.HostName -ne $ip) { + $hostname = $entry.HostName + } + } catch { + # No reverse DNS + } + [PSCustomObject]@{ + IP = $ip + Hostname = $hostname + RoundTrip = $roundTrip + } +} + +$withDNS = @($resolvedHosts | Where-Object { $_.Hostname }) +$withoutDNS = @($resolvedHosts | Where-Object { -not $_.Hostname }) +Write-Status "$($withDNS.Count) resolved via DNS, $($withoutDNS.Count) IP-only" -Level "OK" + +# --------------------------------------------------------------------------- +# Phase 5: Write output file +# --------------------------------------------------------------------------- +$sortedHosts = $resolvedHosts | Sort-Object { [Version]($_.IP -replace '(\d+)\.(\d+)\.(\d+)\.(\d+)', '$1.$2.$3.$4') } + +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add("# Shopfloor PC List - Auto-generated $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") +$lines.Add("# Subnet: $Subnet") +$lines.Add("# Hosts scanned: $($allIPs.Count) | Alive: $($aliveHosts.Count) | WinRM: $($winrmHosts.Count)") +$lines.Add("# Usage: .\Invoke-RemoteMaintenance.ps1 -ComputerListFile `"$OutputFile`" -Task Reboot") +$lines.Add("#") +$lines.Add("# Format: hostname or IP (one per line). Lines starting with # are ignored.") +$lines.Add("") + +foreach ($h in $sortedHosts) { + if ($h.Hostname) { + # Use short hostname (strip domain suffix) as the entry, comment with IP + $shortName = ($h.Hostname -split '\.')[0] + $lines.Add("$shortName # $($h.IP) ($($h.RoundTrip)ms)") + } else { + $lines.Add("$($h.IP) # no DNS ($($h.RoundTrip)ms)") + } +} + +$lines | Out-File -FilePath $OutputFile -Encoding UTF8 +Write-Status "Wrote $($winrmHosts.Count) entries to: $OutputFile" -Level "OK" + +# --------------------------------------------------------------------------- +# Summary table +# --------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== SCAN RESULTS ===" -ForegroundColor White +Write-Host ("{0,-30} {1,-18} {2,8}" -f "HOSTNAME", "IP", "PING(ms)") -ForegroundColor DarkGray +Write-Host ("-" * 58) -ForegroundColor DarkGray +foreach ($h in $sortedHosts) { + $name = if ($h.Hostname) { ($h.Hostname -split '\.')[0] } else { "(no DNS)" } + Write-Host ("{0,-30} {1,-18} {2,8}" -f $name, $h.IP, $h.RoundTrip) +} +Write-Host "" +Write-Host "Output file: $OutputFile" -ForegroundColor Green +Write-Host "Next step: .\Invoke-RemoteMaintenance.ps1 -ComputerListFile `"$OutputFile`" -Task Reboot" -ForegroundColor Yellow diff --git a/remote-execution/INSTRUCTIONS.txt b/remote-execution/INSTRUCTIONS.txt new file mode 100644 index 0000000..618151d --- /dev/null +++ b/remote-execution/INSTRUCTIONS.txt @@ -0,0 +1,100 @@ +============================================================ + Schedule-Maintenance.ps1 - Documentation +============================================================ + + Wrapper script that stores credentials securely and + runs Invoke-RemoteMaintenance.ps1 unattended or on a + schedule via Windows Task Scheduler. + + +============================================================ +REQUIREMENTS +============================================================ + + - PowerShell 5.1+ + - Run as Administrator (required for scheduling only) + - Invoke-RemoteMaintenance.ps1 in the same folder + - A PC list text file (one hostname per line) + + +============================================================ +PARAMETERS +============================================================ + + -SaveCredential Save credentials for unattended use + -Username Domain\username (use with -SaveCredential) + -Password Password (use with -SaveCredential) + -Task Maintenance task name (e.g. Reboot) + -ComputerListFile Path to text file with PC hostnames + -CreateScheduledTask Register a Windows Scheduled Task + -TaskFrequency Daily, Weekly, or Once (default: Weekly) + -TaskDay Day of week (default: Sunday) + -TaskTime Time in HH:mm format (default: 03:00) + -TaskDate Specific date for Once (e.g. 2026-02-22) + + +============================================================ +USAGE +============================================================ + +1. SAVE CREDENTIALS (one time, does not require admin) + + .\Schedule-Maintenance.ps1 -SaveCredential -Username "DS\570005354" -Password "MyP@ssw0rd" + + - Encrypted with Windows DPAPI + - Only your user account on this machine can decrypt + - Re-run if your password changes + + +2. RUN IMMEDIATELY (does not require admin) + + .\Schedule-Maintenance.ps1 -ComputerListFile ".\shopfloor-pcs.txt" -Task Reboot + + +3. SCHEDULE A ONE-TIME TASK (requires admin) + + # Reboot one PC today at 3:00 PM + .\Schedule-Maintenance.ps1 -CreateScheduledTask -ComputerListFile ".\test-reboot.txt" -Task Reboot -TaskFrequency Once -TaskTime "15:00" -TaskDate "2026-02-19" + + # Reboot all PCs Sunday Feb 22 at 12:01 AM + .\Schedule-Maintenance.ps1 -CreateScheduledTask -ComputerListFile ".\shopfloor-pcs.txt" -Task Reboot -TaskFrequency Once -TaskTime "00:01" -TaskDate "2026-02-22" + + +4. SCHEDULE A RECURRING TASK (requires admin) + + # Every Sunday at 12:01 AM + .\Schedule-Maintenance.ps1 -CreateScheduledTask -ComputerListFile ".\shopfloor-pcs.txt" -Task Reboot -TaskFrequency Weekly -TaskDay Sunday -TaskTime "00:01" + + # Every day at 2:00 AM + .\Schedule-Maintenance.ps1 -CreateScheduledTask -ComputerListFile ".\shopfloor-pcs.txt" -Task DiskCleanup -TaskFrequency Daily -TaskTime "02:00" + + +5. MANAGE SCHEDULED TASKS + + Get-ScheduledTask | Where-Object { $_.TaskName -like "ShopfloorMaintenance*" } + Start-ScheduledTask -TaskName "ShopfloorMaintenance-Reboot" + Unregister-ScheduledTask -TaskName "ShopfloorMaintenance-Reboot" + + +============================================================ +LOGS +============================================================ + + .\logs\maintenance-YYYY-MM-DD_HHMMSS-TaskName.log + + +============================================================ +TROUBLESHOOTING +============================================================ + + "No saved credentials found" + -> Run -SaveCredential with -Username and -Password + + "Access is denied" when scheduling + -> Right-click PowerShell -> Run as Administrator + + "No credentials provided. Exiting." + -> GUI prompt failed. Use -Username and -Password flags + + Password changed + -> Re-run -SaveCredential with new password diff --git a/remote-execution/Schedule-Maintenance.ps1 b/remote-execution/Schedule-Maintenance.ps1 new file mode 100644 index 0000000..76c9854 --- /dev/null +++ b/remote-execution/Schedule-Maintenance.ps1 @@ -0,0 +1,208 @@ +<# +.SYNOPSIS + Wrapper for running Invoke-RemoteMaintenance.ps1 unattended via Task Scheduler. + +.DESCRIPTION + Stores credentials securely using Windows DPAPI (encrypted to your user account) + and runs maintenance tasks without interactive prompts. + + First run: use -SaveCredential to store your username/password. + Subsequent runs (or Task Scheduler): credentials load automatically. + +.PARAMETER SaveCredential + Prompt to save credentials for future unattended use. + +.PARAMETER Task + Maintenance task to run (passed to Invoke-RemoteMaintenance.ps1). + +.PARAMETER ComputerListFile + Path to PC list file (passed to Invoke-RemoteMaintenance.ps1). + +.PARAMETER CreateScheduledTask + Register a Windows Scheduled Task to run this script on a schedule. + +.PARAMETER TaskTime + Time for the scheduled task (default: 03:00 AM). + +.PARAMETER TaskFrequency + How often to run: Daily, Weekly, or Once (default: Weekly). + +.PARAMETER TaskDay + Day of week for Weekly frequency (default: Sunday). + +.EXAMPLE + # Step 1: Save your credentials (one time, interactive) + .\Schedule-Maintenance.ps1 -SaveCredential + +.EXAMPLE + # Step 2: Test unattended run + .\Schedule-Maintenance.ps1 -ComputerListFile ".\shopfloor-pcs.txt" -Task Reboot + +.EXAMPLE + # Step 3: Create a scheduled task for weekly reboots + .\Schedule-Maintenance.ps1 -CreateScheduledTask -ComputerListFile ".\shopfloor-pcs.txt" -Task Reboot -TaskFrequency Weekly -TaskDay Sunday -TaskTime "03:00" +#> + +[CmdletBinding()] +param( + [switch]$SaveCredential, + + [string]$Task, + + [string]$ComputerListFile, + + [switch]$CreateScheduledTask, + + [string]$TaskTime = "03:00", + + [ValidateSet('Daily','Weekly','Once')] + [string]$TaskFrequency = "Weekly", + + [ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')] + [string]$TaskDay = "Sunday", + + [string]$TaskDate, + + [string]$Username, + + [string]$Password +) + +$credFile = Join-Path $PSScriptRoot ".maintenance-cred.xml" +$scriptDir = $PSScriptRoot + +# --------------------------------------------------------------------------- +# Save credentials (DPAPI - only decryptable by this user on this machine) +# --------------------------------------------------------------------------- +if ($SaveCredential) { + if ($Username -and $Password) { + $secPass = ConvertTo-SecureString $Password -AsPlainText -Force + $cred = New-Object System.Management.Automation.PSCredential($Username, $secPass) + } else { + Write-Host "Enter the credentials used to connect to remote shopfloor PCs:" -ForegroundColor Cyan + Write-Host "If no prompt appears, re-run with: -Username 'DOMAIN\user' -Password 'pass'" -ForegroundColor Yellow + $cred = Get-Credential -Message "Enter admin credentials for remote shopfloor PCs" + } + if (-not $cred) { + Write-Host "No credentials provided. Exiting." -ForegroundColor Red + exit 1 + } + $cred | Export-Clixml -Path $credFile + Write-Host "Credentials saved to: $credFile" -ForegroundColor Green + Write-Host "Encrypted with DPAPI - only YOUR user account on THIS machine can decrypt them." -ForegroundColor Yellow + Write-Host "" + Write-Host "You can now run tasks unattended:" -ForegroundColor Cyan + Write-Host " .\Schedule-Maintenance.ps1 -ComputerListFile '.\shopfloor-pcs.txt' -Task Reboot" + exit 0 +} + +# --------------------------------------------------------------------------- +# Create Scheduled Task +# --------------------------------------------------------------------------- +if ($CreateScheduledTask) { + if (-not $Task) { + Write-Host "ERROR: -Task is required when creating a scheduled task." -ForegroundColor Red + exit 1 + } + if (-not $ComputerListFile) { + Write-Host "ERROR: -ComputerListFile is required when creating a scheduled task." -ForegroundColor Red + exit 1 + } + + # Resolve to absolute paths + $absListFile = (Resolve-Path $ComputerListFile -ErrorAction Stop).Path + $absScript = Join-Path $scriptDir "Schedule-Maintenance.ps1" + + if (-not (Test-Path $credFile)) { + Write-Host "ERROR: No saved credentials found. Run with -SaveCredential first." -ForegroundColor Red + exit 1 + } + + $taskName = "ShopfloorMaintenance-$Task" + $psArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$absScript`" -Task `"$Task`" -ComputerListFile `"$absListFile`"" + + $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $psArgs -WorkingDirectory $scriptDir + + $triggerTime = if ($TaskDate) { + [DateTime]::Parse("$TaskDate $TaskTime") + } else { + [DateTime]::Parse($TaskTime) + } + + $trigger = switch ($TaskFrequency) { + 'Daily' { New-ScheduledTaskTrigger -Daily -At $triggerTime } + 'Weekly' { New-ScheduledTaskTrigger -Weekly -DaysOfWeek $TaskDay -At $triggerTime } + 'Once' { New-ScheduledTaskTrigger -Once -At $triggerTime } + } + + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RunOnlyIfNetworkAvailable + + # Register as current user (needed for DPAPI credential decryption) + $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + Write-Host "Creating scheduled task '$taskName' as $currentUser..." -ForegroundColor Cyan + Write-Host " Schedule: $TaskFrequency at $TaskTime$(if ($TaskFrequency -eq 'Weekly') { " ($TaskDay)" })" -ForegroundColor Gray + Write-Host " Task: $Task" -ForegroundColor Gray + Write-Host " PC List: $absListFile" -ForegroundColor Gray + + $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($existingTask) { + Write-Host "Task '$taskName' already exists. Updating..." -ForegroundColor Yellow + Set-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings | Out-Null + } else { + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -User $currentUser -RunLevel Highest -Description "Shopfloor PC Maintenance - $Task (via Invoke-RemoteMaintenance.ps1)" | Out-Null + } + + Write-Host "Scheduled task '$taskName' created successfully." -ForegroundColor Green + Write-Host "" + Write-Host "Manage in Task Scheduler or with:" -ForegroundColor Cyan + Write-Host " Get-ScheduledTask -TaskName '$taskName'" + Write-Host " Start-ScheduledTask -TaskName '$taskName' # run now" + Write-Host " Unregister-ScheduledTask -TaskName '$taskName' # delete" + exit 0 +} + +# --------------------------------------------------------------------------- +# Run maintenance (unattended) +# --------------------------------------------------------------------------- +if (-not $Task) { + Write-Host "ERROR: -Task is required. Example: -Task Reboot" -ForegroundColor Red + Write-Host " Or use -SaveCredential to store credentials first." -ForegroundColor Yellow + Write-Host " Or use -CreateScheduledTask to set up a scheduled run." -ForegroundColor Yellow + exit 1 +} + +if (-not $ComputerListFile) { + Write-Host "ERROR: -ComputerListFile is required." -ForegroundColor Red + exit 1 +} + +# Load saved credentials +if (-not (Test-Path $credFile)) { + Write-Host "ERROR: No saved credentials found at $credFile" -ForegroundColor Red + Write-Host "Run with -SaveCredential first to store credentials." -ForegroundColor Yellow + exit 1 +} + +try { + $cred = Import-Clixml -Path $credFile + Write-Host "Loaded saved credentials for: $($cred.UserName)" -ForegroundColor Green +} catch { + Write-Host "ERROR: Failed to load credentials: $_" -ForegroundColor Red + Write-Host "Re-run with -SaveCredential to save new credentials." -ForegroundColor Yellow + exit 1 +} + +# Log output +$logDir = Join-Path $scriptDir "logs" +if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } +$logFile = Join-Path $logDir "maintenance-$(Get-Date -Format 'yyyy-MM-dd_HHmmss')-$Task.log" + +Write-Host "Running: Invoke-RemoteMaintenance.ps1 -Task $Task -ComputerListFile $ComputerListFile" -ForegroundColor Cyan +Write-Host "Log: $logFile" -ForegroundColor Gray + +$mainScript = Join-Path $scriptDir "Invoke-RemoteMaintenance.ps1" + +& $mainScript -ComputerListFile $ComputerListFile -Task $Task -Credential $cred 2>&1 | Tee-Object -FilePath $logFile + +Write-Host "" +Write-Host "Complete. Log saved to: $logFile" -ForegroundColor Green