From 21fcc5a7a08e9cd9f96ce09509c58c7249d11276 Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:23:09 -0400 Subject: [PATCH 01/13] Prepare unified qBtGuard candidate with gated enforcement and Windows QA --- .github/workflows/windows-tests.yml | 18 +++ .gitignore | 5 + Install-QbtGuard.ps1 | 20 +-- MIGRATION.md | 14 ++ QbtGuard.ps1 | 243 ++++++++++++++++++++++++---- README.md | 86 +++++----- Run-QbtGuard.vbs | 15 +- Uninstall-QbtGuard.ps1 | 5 +- config.example.json | 19 ++- tests/QbtGuard.Api.Tests.ps1 | 67 +++++++- tests/QbtGuard.Control.Tests.ps1 | 113 +++++++++++++ tests/QbtGuard.Tests.ps1 | 10 -- tests/QbtGuard.Uninstall.Tests.ps1 | 1 + tests/README.md | 23 ++- 14 files changed, 514 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/windows-tests.yml create mode 100644 MIGRATION.md create mode 100644 tests/QbtGuard.Control.Tests.ps1 diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml new file mode 100644 index 0000000..3d966f0 --- /dev/null +++ b/.github/workflows/windows-tests.yml @@ -0,0 +1,18 @@ +name: Windows QA +on: [push, pull_request] +permissions: + contents: read +jobs: + regression: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: PowerShell 5.1 syntax and regression tests + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object { $tokens=$null; $errors=$null; $null=[Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$tokens,[ref]$errors); if ($errors.Count) { throw ($errors | Out-String) } } + Install-Module Pester -RequiredVersion 4.10.1 -Force -Scope CurrentUser -SkipPublisherCheck + Import-Module Pester -RequiredVersion 4.10.1 + $result = Invoke-Pester -Script ./tests -PassThru + if ($result.FailedCount -gt 0) { throw 'Regression tests failed.' } diff --git a/.gitignore b/.gitignore index 5a49f05..adb6a97 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ credential.dpapi *.bak *.zip .DS_Store + +region-state.json +run.lock +*password*.txt +*alerts*.txt diff --git a/Install-QbtGuard.ps1 b/Install-QbtGuard.ps1 index 2d4f6a2..1977c59 100644 --- a/Install-QbtGuard.ps1 +++ b/Install-QbtGuard.ps1 @@ -1,12 +1,13 @@ -# qBtGuard 0.1.0-preview installer. Run as the intended scheduled-task Windows user. +# qBtGuard 0.2.0 candidate installer. Run as the intended scheduled-task Windows user. [CmdletBinding()] param( [string]$Destination = (Join-Path $env:LOCALAPPDATA 'qBtGuard'), - [switch]$NoTask + [switch]$NoTask, + [string]$TaskName = 'qBtGuard Audit' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$taskName = 'qBtGuard Audit' +$taskName = $TaskName $staging = $null $installed = $false $taskCreated = $false @@ -26,7 +27,7 @@ try { if (Test-Path -LiteralPath $destinationFull) { throw "Destination already exists: $destinationFull. No changes made." } $parent = Split-Path -Parent $destinationFull if (-not (Test-Path -LiteralPath $parent -PathType Container)) { throw "Destination parent does not exist: $parent" } - Write-Host 'qBtGuard preview: READ-ONLY. No torrent deletion or PIA changes.' + Write-Host 'qBtGuard candidate: audit mode by default; corrective actions require configuration and QA.' $url = Read-Host 'qBittorrent Web UI URL [http://127.0.0.1:8080]' if ([string]::IsNullOrWhiteSpace($url)) { $url = 'http://127.0.0.1:8080' } $uri = [uri]$url @@ -45,11 +46,9 @@ try { $null = Invoke-RestMethod -Uri ($baseUrl + '/api/v2/app/version') -WebSession $session -Headers $headers -TimeoutSec 15 -ErrorAction Stop $null = Invoke-RestMethod -Uri ($baseUrl + '/api/v2/torrents/info') -WebSession $session -Headers $headers -TimeoutSec 30 -ErrorAction Stop Write-Host 'Authentication and read-only API access verified.' - $config = [ordered]@{ - QbtUrl=$baseUrl; QbtUser=$user; BlockNoExtension=$true; VerifyMkvSignatures=$true - BlockedExtensions=@('.exe','.scr','.com','.bat','.cmd','.msi','.msp','.ps1','.psm1','.vbs','.vbe','.js','.jse','.wsf','.wsh','.hta','.cpl','.pif','.lnk','.jar','.zipx') - AuditOnly=$true; IntervalMinutes=10 - } + $config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'config.example.json') -Raw | ConvertFrom-Json + $config.QbtUrl = $baseUrl; $config.QbtUser = $user + $config.AuditOnly = $true $staging = Join-Path $parent ('.qBtGuard-staging-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $staging -ErrorAction Stop | Out-Null Copy-Item -LiteralPath $source -Destination (Join-Path $staging 'QbtGuard.ps1') -ErrorAction Stop @@ -65,7 +64,7 @@ try { $trigger = New-ScheduledTaskTrigger -Daily -At (Get-Date).AddMinutes(2) $trigger.Repetition = New-CimInstance -Namespace 'Root/Microsoft/Windows/TaskScheduler' -ClassName 'MSFT_TaskRepetitionPattern' -ClientOnly -Property @{ Interval = 'PT10M'; Duration = 'P1D'; StopAtDurationEnd = $false } $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited - $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5) + $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 9) Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description 'qBtGuard read-only audit; runs while user is logged on' -ErrorAction Stop | Out-Null $taskCreated = $true } @@ -84,3 +83,4 @@ try { try { Invoke-WebRequest -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/auth/logout') -Method Post -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | Out-Null } catch {} } } + diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..6c120dd --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,14 @@ +# Consolidation and enforcement QA + +This procedure is for anyone replacing older qBittorrent/PIA scripts. No production changes have been performed by this branch. + +1. Inventory all tasks and startup launchers targeting qBittorrent or PIA. Record their accounts, paths, intervals, current configuration and capabilities. Back up task XML, scripts, credentials and persistent state privately. +2. Install this candidate into a new directory with `-NoTask`. Retain the old working installation. Configure the candidate for the actual machine and VPN adapter; keep audit mode enabled. Do not reuse another Windows user's DPAPI credential. +3. Run Windows syntax and mocked regression tests. Run a live audit and confirm API authentication, interface binding, forwarded-port discovery and MKV coverage. Compare results to the existing script. Concurrent old automation can remove findings before an audit sees them. +4. On an isolated test instance, exercise each enforced action independently: prohibited extension, extensionless file, invalid header, unreadable/missing MKV, healthy restart, blocked restart, port change, reconnecting VPN, explicit forwarding failure, recovery timeout and corrupt cooldown file. Use disposable torrent data. Verify no mutation in audit mode and no unintended deletion on failures. +5. Confirm the intended qBittorrent profile and adapter names agree before enabling restart. This candidate supports the 4.3.6 `Connection\Interface` profile key; other profile layouts fail closed and need an explicit implementation/test update. +6. For cutover, first disable the verified old tasks and wait for all their active runs to finish. Keep backups and do not delete the old installation. Enable the desired candidate action flags and enforcement mode, manually verify a cycle, then register exactly one task using the candidate launcher and intended Windows account. The stock installer refuses existing destinations/tasks; task replacement must be performed separately after review. +7. Verify the hidden launcher propagates a failure exit code, the task succeeds every ten minutes, and log summaries show actual coverage. Confirm kill-switch and qBittorrent binding behavior while PIA is disconnected. +8. For rollback, disable and stop the new task/run before re-enabling the old task. Restore old configuration/state if altered. Deleted torrent data is irreversible through this script; recover from independent backups if available. + +Keep audit mode on and existing production protection in place until the candidate clears QA. The old deletion script may remain enforced during read-only comparison; never leave both enforcement engines scheduled after cutover. diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 7d1e587..f812201 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -1,17 +1,130 @@ -# qBtGuard 0.1.0-preview - read-only audit engine +# qBtGuard 0.2.0 candidate: unified audit and opt-in enforcement. [CmdletBinding()] param([string]$InstallPath) if ([string]::IsNullOrWhiteSpace($InstallPath)) { $InstallPath = Split-Path -Parent $PSCommandPath } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$configFile = Join-Path $InstallPath 'config.json' -if (-not (Test-Path -LiteralPath $configFile)) { throw 'Missing config.json. Run Install-QbtGuard.ps1.' } -$c = Get-Content -LiteralPath $configFile -Raw | ConvertFrom-Json $log = Join-Path $InstallPath 'qbtguard.log' function Write-GuardLog([string]$Message) { - $line = '{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message - Add-Content -LiteralPath $log -Value $line - Write-Output $line + $clean = $Message -replace '[\r\n\x00-\x1f]', ' ' + Add-Content -LiteralPath $log -Value ('{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $clean) + Write-Output $clean +} +function Get-Setting([string]$Name, $Default) { + $property = $c.PSObject.Properties[$Name] + if ($null -eq $property) { return $Default } + return $property.Value +} +function Invoke-GuardAction([string]$Name, [scriptblock]$Action) { + if ($auditOnly) { Write-GuardLog "WOULD $Name"; return $false } + & $Action | Out-Null + Write-GuardLog "ACTION $Name" + return $true +} +function Invoke-Pia([string[]]$Arguments) { + # Use ProcessStartInfo for bounded execution and explicit exit status. + # All arguments come from fixed verbs or validated region IDs. + $si = New-Object Diagnostics.ProcessStartInfo + $si.FileName = [string](Get-Setting 'PiaCtl' '') + $si.Arguments = $Arguments -join ' ' + $si.UseShellExecute = $false; $si.CreateNoWindow = $true + $si.RedirectStandardOutput = $true; $si.RedirectStandardError = $true + $process = New-Object Diagnostics.Process + $process.StartInfo = $si + try { + if (-not $process.Start()) { throw 'PIA command did not start.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(10000)) { $process.Kill(); throw 'PIA command timed out.' } + if ($process.ExitCode -ne 0) { throw 'PIA command failed.' } + return $stdout.Result.Trim() + } finally { $process.Dispose() } +} +function Test-LocalEndpoint([uri]$Address) { + $local = @('127.0.0.1','::1') + @(Get-NetIPAddress | Select-Object -ExpandProperty IPAddress) + $resolved = @([Net.Dns]::GetHostAddresses($Address.DnsSafeHost) | ForEach-Object { $_.ToString() }) + return ($resolved.Count -gt 0 -and @($resolved | Where-Object { $_ -notin $local }).Count -eq 0) +} +function Test-VpnReady { + if ((Invoke-Pia @('get','connectionstate')) -ne 'Connected') { return $false } + $vpnIP = Invoke-Pia @('get','vpnip') + $ip = $null + if (-not [Net.IPAddress]::TryParse($vpnIP, [ref]$ip)) { return $false } + $addresses = @(Get-NetIPAddress -ErrorAction Stop | Where-Object { $_.IPAddress -eq $vpnIP }) + if ($addresses.Count -ne 1) { return $false } + $adapter = Get-NetAdapter -InterfaceIndex $addresses[0].InterfaceIndex -ErrorAction Stop + $expected = [string](Get-Setting 'VpnInterface' '') + return ($adapter.Status -eq 'Up' -and -not [string]::IsNullOrWhiteSpace($expected) -and $adapter.Name -eq $expected) +} +function Test-StartupBinding { + # Read the intended qBittorrent profile before allowing a process start. + $path = [string](Get-Setting 'QbtSettingsPath' '') + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false } + $ini = Get-Content -LiteralPath $path + $bindings = @($ini | Where-Object { $_ -match '^Connection\\Interface=' }) + if ($bindings.Count -ne 1) { return $false } + $binding = ($bindings[0] -split '=',2)[1].Trim() + return ($binding -eq [string](Get-Setting 'VpnInterface' '') -and $binding -ne '') +} +function Invoke-QbtApi([string]$Endpoint, [string]$Method = 'Get', $Body = $null) { + $args = @{ Uri=($baseUrl + '/api/v2/' + $Endpoint); Method=$Method; WebSession=$session; Headers=$headers; TimeoutSec=30; ErrorAction='Stop' } + if ($null -ne $Body) { $args.Body = $Body } + Invoke-RestMethod @args +} +function Get-ForwardedPort { + $output = Invoke-Pia @('get','portforward') + if ($output -match '^\d{1,5}$' -and [int]$output -ge 1 -and [int]$output -le 65535) { return [int]$output } + return $null +} +function Invoke-VpnRecovery { + $state = Invoke-Pia @('get','connectionstate') + $portState = Invoke-Pia @('get','portforward') + if ($state -eq 'Connected' -and $portState -ne 'Failed') { return } + if (-not (Get-Setting 'EnableVpnRecovery' $false)) { Write-GuardLog "NOTICE VPN recovery disabled; state=$state port=$portState"; return } + $statePath = Join-Path $InstallPath 'region-state.json' + if (Test-Path -LiteralPath $statePath) { + # A corrupt cooldown record blocks recovery rather than causing rapid rotation. + $last = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json + $elapsed = ([DateTimeOffset]::UtcNow - [DateTimeOffset]::Parse($last.LastAttemptUtc)).TotalMinutes + if ($elapsed -lt [int](Get-Setting 'RegionCooldownMinutes' 10)) { Write-GuardLog 'NOTICE VPN recovery cooldown active'; return } + } + $regions = @(Get-Setting 'PiaRegions' @()) + if ($regions.Count -lt 2) { throw 'Configure at least two PIA region IDs.' } + foreach ($region in $regions) { if ([string]$region -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') { throw 'Invalid PIA region ID.' } } + $current = Invoke-Pia @('get','region') + $index = [Array]::IndexOf($regions, $current) + $next = [string]$regions[(($index + 1) % $regions.Count)] + $changed = Invoke-GuardAction "switch PIA region to $next and reconnect" { + @{LastAttemptUtc=[DateTimeOffset]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + Invoke-Pia @('set','region',$next) | Out-Null + Invoke-Pia @('connect') | Out-Null + } + if (-not $changed) { return } + $deadline = [DateTime]::UtcNow.AddSeconds(120) + do { + if ((Invoke-Pia @('get','region')) -eq $next -and (Test-VpnReady) -and $null -ne (Get-ForwardedPort)) { Write-GuardLog 'VPN recovery verified'; return } + Start-Sleep -Seconds 5 + } while ([DateTime]::UtcNow -lt $deadline) + throw 'VPN recovery did not become ready within 120 seconds.' +} +function Ensure-QbtRunning { + $processName = [string](Get-Setting 'QbtProcessName' 'qbittorrent') + if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } + if (-not (Get-Setting 'EnableQbtRestart' $false)) { Write-GuardLog 'NOTICE qBittorrent absent; restart disabled'; return $false } + if (-not (Test-VpnReady) -or -not (Test-StartupBinding)) { throw 'Restart blocked: VPN readiness or saved interface binding not verified.' } + $exe = [string](Get-Setting 'QbtExe' '') + if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw 'qBittorrent executable missing.' } + $started = Invoke-GuardAction 'restart qBittorrent' { + if (-not (Test-VpnReady)) { throw 'VPN changed before restart.' } + Start-Process -FilePath $exe -WindowStyle Minimized | Out-Null + } + if (-not $started) { return $false } + $deadline = [DateTime]::UtcNow.AddSeconds(60) + do { + if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } + Start-Sleep -Seconds 2 + } while ([DateTime]::UtcNow -lt $deadline) + throw 'qBittorrent process restart timed out.' } function Get-MkvIssue([string]$Path) { $stream = $null @@ -61,44 +174,116 @@ function Resolve-TorrentFile($Torrent, [string]$Name) { } return $null } -$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession -$headers = $null -$uri = $null + +$session = $null; $headers = $null; $uri = $null; $lock = $null +$loggedIn = $false +$stage = 'configuration' try { + # Exclusive lock covers manual and scheduled runs from this installation. + try { $lock = [IO.File]::Open((Join-Path $InstallPath 'run.lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } + catch [IO.IOException] { Write-Output 'qBtGuard already running or lock unavailable; cycle skipped.'; return } + $c = Get-Content -LiteralPath (Join-Path $InstallPath 'config.json') -Raw | ConvertFrom-Json + $auditOnly = Get-Setting 'AuditOnly' $true + if ($auditOnly -isnot [bool]) { throw 'AuditOnly must be a JSON boolean.' } + foreach ($name in @('EnablePia','EnableVpnRecovery','EnablePortSync','EnableQbtRestart','DeleteProhibitedTorrents','DeleteInvalidMkvTorrents','BlockNoExtension','VerifyMkvSignatures','RemoteEndpoint')) { + if ((Get-Setting $name $false) -isnot [bool]) { throw "Invalid boolean setting: $name" } + } $uri = [uri]([string]$c.QbtUrl) - if ($uri.Scheme -notin @('http','https')) { throw 'Web UI URL must use http or https.' } + if (-not $uri.IsAbsoluteUri -or $uri.Scheme -notin @('http','https') -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) { throw 'Invalid Web UI URL.' } + $baseUrl = $uri.AbsoluteUri.TrimEnd('/') + $piaEnabled = Get-Setting 'EnablePia' $false + $remote = Get-Setting 'RemoteEndpoint' $false + if ($remote -and ($piaEnabled -or (Get-Setting 'EnableQbtRestart' $false))) { throw 'Remote endpoint cannot use local PIA or watchdog controls.' } + if ((Get-Setting 'EnableQbtRestart' $false) -and -not $piaEnabled) { throw 'Restart requires PIA integration.' } + if (-not $piaEnabled -and ((Get-Setting 'EnableVpnRecovery' $false) -or (Get-Setting 'EnablePortSync' $false))) { throw 'VPN recovery and port sync require PIA integration.' } + if ($piaEnabled) { + $stage = 'VPN' + if (-not (Test-LocalEndpoint $uri)) { throw 'PIA endpoint must resolve exclusively to this computer.' } + Invoke-VpnRecovery + if (-not (Test-VpnReady)) { throw 'VPN and configured interface not ready; cycle blocked.' } + if (-not (Ensure-QbtRunning)) { return } + } + $stage = 'authentication' $secure = (Get-Content -LiteralPath (Join-Path $InstallPath 'credential.dpapi') -Raw).Trim() | ConvertTo-SecureString $credential = New-Object System.Management.Automation.PSCredential([string]$c.QbtUser, $secure) - $headers = @{ Referer = ($uri.AbsoluteUri.TrimEnd('/') + '/'); Origin = $uri.GetLeftPart([UriPartial]::Authority) } - $login = Invoke-WebRequest -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/auth/login') -Method Post -WebSession $session -Headers $headers -Body @{ username = [string]$c.QbtUser; password = $credential.GetNetworkCredential().Password } -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop - if ($login.Content.Trim() -ne 'Ok.') { throw 'qBittorrent authentication failed.' } - $torrents = @(Invoke-RestMethod -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/torrents/info') -WebSession $session -Headers $headers -TimeoutSec 30 -ErrorAction Stop | ForEach-Object { $_ }) + $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession + $headers = @{ Referer=($baseUrl + '/'); Origin=$uri.GetLeftPart([UriPartial]::Authority) } + $deadline = [DateTime]::UtcNow.AddSeconds(60) + do { + try { + $login = Invoke-WebRequest -Uri ($baseUrl + '/api/v2/auth/login') -Method Post -WebSession $session -Headers $headers -Body @{username=[string]$c.QbtUser; password=$credential.GetNetworkCredential().Password} -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop + if ($login.Content.Trim() -ne 'Ok.') { throw 'Authentication rejected.' } + $loggedIn = $true + } catch { + if (-not $piaEnabled -or [DateTime]::UtcNow -ge $deadline) { throw } + Start-Sleep -Seconds 2 + } + } until ($loggedIn) + $stage = 'preferences' + if ($piaEnabled) { + $prefs = Invoke-QbtApi 'app/preferences' + if ([string]$prefs.current_network_interface -ne [string](Get-Setting 'VpnInterface' '')) { throw 'Live qBittorrent VPN interface binding mismatch.' } + if (Get-Setting 'EnablePortSync' $false) { + $port = Get-ForwardedPort + if ($null -ne $port -and [int]$prefs.listen_port -ne $port) { + $changed = Invoke-GuardAction "set qBittorrent listening port to $port" { + if (-not (Test-VpnReady) -or (Get-ForwardedPort) -ne $port) { throw 'VPN or port changed before synchronization.' } + Invoke-QbtApi 'app/setPreferences' 'Post' @{json=(@{listen_port=$port} | ConvertTo-Json -Compress)} | Out-Null + } + if ($changed -and [int](Invoke-QbtApi 'app/preferences').listen_port -ne $port) { throw 'Port change verification failed.' } + } elseif ($null -eq $port) { Write-GuardLog 'NOTICE no valid PIA forwarded port available' } + } + } + $stage = 'file scan' + $torrents = @(Invoke-QbtApi 'torrents/info' | ForEach-Object { $_ }) $blocked = @($c.BlockedExtensions | ForEach-Object { ([string]$_).ToLowerInvariant() }) - $checked = 0; $findings = 0 + $checked = 0; $findings = 0; $mkvChecked = 0; $mkvSkipped = 0; $mkvUnreadable = 0; $deleted = 0 foreach ($torrent in $torrents) { - if ($null -eq $torrent -or -not $torrent.hash) { continue } - $files = @(Invoke-RestMethod -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/torrents/files?hash=' + [uri]::EscapeDataString([string]$torrent.hash)) -WebSession $session -Headers $headers -TimeoutSec 30 -ErrorAction Stop | ForEach-Object { $_ }) + if ($null -eq $torrent -or [string]$torrent.hash -notmatch '^[a-fA-F0-9]{40,64}$') { throw 'Invalid torrent identity returned by API.' } + $hash = [string]$torrent.hash + $files = @(Invoke-QbtApi ('torrents/files?hash=' + $hash) | ForEach-Object { $_ }) + $prohibited = $false; $invalidMkv = $false foreach ($file in $files) { if ($null -eq $file) { continue } $checked++ $name = [string]$file.name $ext = [IO.Path]::GetExtension($name).ToLowerInvariant() - if (($c.BlockNoExtension -and [string]::IsNullOrWhiteSpace($ext)) -or ($blocked -contains $ext)) { - $findings++ - Write-GuardLog ('ALERT prohibited file: torrent="{0}" file="{1}" extension="{2}"' -f $torrent.name, $name, $ext) + if (((Get-Setting 'BlockNoExtension' $true) -and [string]::IsNullOrWhiteSpace($ext)) -or $ext -in $blocked) { + $prohibited = $true; $findings++ + Write-GuardLog ('ALERT prohibited file: torrent="{0}" file="{1}"' -f $torrent.name, $name) } - if ($c.VerifyMkvSignatures -and $ext -eq '.mkv' -and [double]$torrent.progress -ge 1 -and [double]$file.progress -ge 1) { + if ((Get-Setting 'VerifyMkvSignatures' $true) -and $ext -eq '.mkv') { + if ([double]$torrent.progress -lt 1 -or [double]$file.progress -lt 1 -or $remote) { $mkvSkipped++; continue } $path = Resolve-TorrentFile $torrent $name - if (-not $path) { Write-GuardLog ('NOTICE MKV unavailable: torrent="{0}" file="{1}"' -f $torrent.name, $name); continue } + if (-not $path) { $mkvSkipped++; Write-GuardLog ('NOTICE MKV unavailable: file="{0}"' -f $name); continue } + $mkvChecked++ $issue = Get-MkvIssue $path - if ($issue) { $findings++; Write-GuardLog ('ALERT MKV signature: torrent="{0}" file="{1}" issue="{2}"' -f $torrent.name, $name, $issue) } + if ($issue) { + $findings++ + if ($issue -like 'UNREADABLE:*') { $mkvUnreadable++ } else { $invalidMkv = $true } + Write-GuardLog ('ALERT MKV signature: file="{0}" issue="{1}"' -f $name, $issue) + } + } + } + if (($prohibited -and (Get-Setting 'DeleteProhibitedTorrents' $false)) -or ($invalidMkv -and (Get-Setting 'DeleteInvalidMkvTorrents' $false))) { + $didDelete = Invoke-GuardAction "delete torrent $hash AND downloaded data" { + # Recheck existence before deletion; never call direct filesystem deletion. + $present = @(Invoke-QbtApi ('torrents/info?hashes=' + $hash) | ForEach-Object { $_ }) + if ($present.Count -ne 1 -or [string]$present[0].hash -ne $hash) { throw 'Torrent changed or disappeared before deletion.' } + Invoke-QbtApi 'torrents/delete' 'Post' @{hashes=$hash; deleteFiles='true'} | Out-Null + if (@(Invoke-QbtApi ('torrents/info?hashes=' + $hash) | ForEach-Object { $_ }).Count -ne 0) { throw 'Torrent removal not verified.' } } + if ($didDelete) { $deleted++ } } } - Write-GuardLog ("AUDIT completed: files=$checked findings=$findings. No changes made.") -} catch { Write-GuardLog ('ERROR: audit failed (' + $_.Exception.GetType().Name + '). Check Web UI connectivity, credentials, and configuration.'); exit 1 } -finally { - if ($null -ne $session -and $null -ne $headers -and $null -ne $uri) { - try { Invoke-WebRequest -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/auth/logout') -Method Post -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | Out-Null } catch {} + $mode = 'ENFORCE'; if ($auditOnly) { $mode = 'AUDIT' } + Write-GuardLog "$mode completed: files=$checked findings=$findings mkvChecked=$mkvChecked mkvSkipped=$mkvSkipped mkvUnreadable=$mkvUnreadable torrentsRemoved=$deleted" +} catch { + Write-GuardLog ('ERROR: cycle failed at ' + $stage + ' (' + $_.Exception.GetType().Name + '). No successful completion recorded.') + exit 1 +} finally { + if ($loggedIn) { + try { Invoke-WebRequest -Uri ($baseUrl + '/api/v2/auth/logout') -Method Post -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | Out-Null } catch {} } + if ($null -ne $lock) { $lock.Dispose() } } diff --git a/README.md b/README.md index 2810087..5896055 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,61 @@ -# qBtGuard — 0.1.0 preview +# qBtGuard — 0.2.0 candidate -**Status: experimental, read-only auditor. Validated on one Windows installation; independent clean-VM testing remains outstanding.** +One Windows PowerShell 5.1 engine for torrent inspection, opt-in deletion, PIA recovery, forwarded-port synchronization, and qBittorrent restart. **This branch is a QA candidate, not an enforcement release.** -qBtGuard is a Windows PowerShell utility for auditing torrents managed by qBittorrent. It reports potentially dangerous file extensions, extensionless files, and completed `.mkv` files that do not begin with the EBML container signature. +## Defaults and operation -**This preview never deletes torrents or downloads, starts or stops qBittorrent, or changes PIA settings.** It is **not a VPN kill switch**. The source project's VPN watchdog, port-forwarding synchronization, region failover and automatic deletion are intentionally excluded pending safety and portability testing. +Installation starts in audit mode (`AuditOnly: true`). All corrective actions pass through a shared action gate: audit mode logs what would happen without switching VPN regions, changing ports, starting qBittorrent, or deleting torrents. Authentication/logout, logs and a temporary execution lock still operate. -## Requirements +Set `AuditOnly: false` only after QA and enable each desired action separately. Flags default to false, including on older configurations. `DeleteProhibitedTorrents` removes the **entire offending torrent AND its downloaded data** through qBittorrent. `DeleteInvalidMkvTorrents` is separate and defaults off. An unreadable MKV alone never causes deletion. -- Windows 10/11, Windows PowerShell 5.1 and the ScheduledTasks module for scheduled operation. -- qBittorrent Web UI enabled with API v2; first compatibility target is qBittorrent 4.3.6. -- qBittorrent Web UI over HTTP or HTTPS. HTTP is supported on trusted LANs, but sends Web UI credentials and session data unencrypted. Do not expose an HTTP Web UI to the public Internet; use HTTPS or a secure tunnel for untrusted networks. -- The scheduled-task account must be able to access downloads, including network shares. +| Setting | Meaning | +|---|---| +| `QbtUrl`, `QbtUser` | User-supplied Web UI connection | +| `RemoteEndpoint` | Explicit remote API mode; skips local MKV inspection and blocks local PIA/restart controls | +| `EnablePia` | Require local PIA tunnel and qBittorrent interface binding | +| `EnableVpnRecovery` | Rotate configured PIA regions after disconnection or explicit forwarding failure | +| `EnablePortSync` | Apply and verify PIA's forwarded port | +| `EnableQbtRestart` | Start a missing local process after VPN and saved profile binding checks | +| `VpnInterface` | Exact local VPN adapter name; must also match the live qBittorrent interface preference | +| `QbtSettingsPath` | Intended user's qBittorrent INI file; restart requires its `Connection\Interface` value to match | +| `PiaCtl`, `QbtExe`, `QbtProcessName` | Local installation paths and process name | +| `PiaRegions`, `RegionCooldownMinutes` | User-selected region IDs and persistent recovery cooldown | +| `BlockNoExtension`, `BlockedExtensions` | File-name inspection policy | +| `VerifyMkvSignatures` | Inspect completed local MKV headers | +| `DeleteProhibitedTorrents`, `DeleteInvalidMkvTorrents` | Separate opt-in deletion policies | -PIA is **not required** for this audit-only preview. +PIA integration is optional. With it enabled, the API endpoint must resolve exclusively to this machine. The tunnel must report Connected, have a local VPN IP on the configured Up adapter, and qBittorrent must report the configured interface binding. qBtGuard is **not a kill switch**: configure PIA's kill switch and qBittorrent's interface binding independently. It does not stop an already running qBittorrent if the tunnel fails. Recovery can take up to 120 seconds; the scheduler prevents concurrent scheduled instances and the engine locks each installation against concurrent manual runs. -## Installation - -1. Download and extract the repository; inspect the PowerShell scripts before running them. -2. Open Windows PowerShell as the Windows account that will run the audit. -3. From the extracted folder, run: - `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-QbtGuard.ps1` -4. Enter the Web UI address, username, and password. The password is stored with Windows DPAPI, not in the JSON configuration. -5. Test manually: - `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\qBtGuard\QbtGuard.ps1"` -6. Inspect `%LOCALAPPDATA%\qBtGuard\qbtguard.log`. +The first compatibility target is qBittorrent 4.3.6. Profile/interface naming, PIA behavior, x86/x64 paths and other versions require Windows validation. No user's addresses, credentials, logs, or preferred region list are embedded in the engine. -The `qBtGuard Audit` scheduled task repeats every 10 minutes **while the installing Windows user is logged in**. It launches `Run-QbtGuard.vbs` through `wscript.exe` (`//B //Nologo`), which runs the PowerShell audit with its console window hidden. The scheduled audit writes results to `qbtguard.log` rather than displaying a console window. The installer must be kept alongside `Run-QbtGuard.vbs` and `QbtGuard.ps1` in the extracted source directory. Run the installer with `-NoTask` to omit scheduling. The installer refuses to create a task if a task named `qBtGuard Audit` already exists; it does not replace the existing task. DPAPI credentials cannot generally be decrypted from a different user account. +## Installation -## Uninstall +Keep the installer, engine, launcher and `config.example.json` together. Run as the Windows user who will run the task: -Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Uninstall-QbtGuard.ps1` from the extracted source directory. The uninstaller removes a `qBtGuard Audit` task only when its launcher matches the specified installation directory; otherwise it refuses to remove it. The installed directory, settings and logs are retained for inspection. Downloads are never touched. +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-QbtGuard.ps1 +``` -## Security limitations +The installer asks for URL/user/password and saves the password with Windows DPAPI. The default destination is `%LOCALAPPDATA%\qBtGuard`. It refuses existing destinations and existing task names. Use `-NoTask` for isolated QA; `-TaskName` permits a distinct test task. The task repeats every ten minutes while the installing user is logged on and runs hidden. Do not schedule multiple installations against the same qBittorrent instance. -- This is a **report-only** preview, not antivirus software or a guaranteed security boundary. -- An EBML header check does not establish that a file is playable or safe. -- MKV header checks apply only to files the API reports complete. Missing files are reported as unavailable. -- Blocked extensions are reported even if the torrent is incomplete. -- Logs can contain torrent and file names: treat logs as private. MKV file-read failures use a generic error message instead of logging raw filesystem exceptions. -- Secure your qBittorrent Web UI; do not expose it directly to the public Internet. -- The preview has **not** yet been tested on a clean Windows VM, additional qBittorrent versions, alternate PIA versions, or all UNC path arrangements. +After installation edit only the installed `config.json`. Paths, interface names, and PIA regions are configuration, not repository edits. For remote Web UI use set `RemoteEndpoint: true`; deletion still acts on that remote server if explicitly enabled. Remote downloads are not mapped into local paths by this candidate. -## Validation completed on the initial Windows test system +Run the installed engine manually and inspect its `qbtguard.log`. Completion entries include file findings, MKVs checked/skipped/unreadable, and torrent removals. A zero finding count with skipped MKVs does not mean every video was inspected. Header inspection accepts EBML, not full container integrity, codec validity, playable media, or absence of malware. MP4 or another legitimate format renamed `.mkv` will be flagged, so leave invalid-MKV deletion off until that policy is reviewed. No age-based disk cleanup is included. -- 26 Pester 3.4.0 regression tests passed, covering mocked API failures, MKV headers, log privacy, path safety, read-only safeguards, and uninstaller task ownership. -- A clean installation using `-NoTask` authenticated successfully and completed a read-only audit of 3,638 files with zero findings. -- The VBScript launcher ran invisibly and wrote a successful audit completion entry. -- An existing scheduled task was observed running at approximately 10-minute intervals with successful completion logs. **This does not independently validate task creation by the installer.** -- A Gitleaks scan of the cloned repository history reported no detected leaks. Automated scanning does not guarantee the absence of secrets. +API calls and PIA commands have timeouts. Failures log a stage and exception type rather than raw credential-bearing exceptions. Logs contain file/torrent names and must remain private. The engine rechecks torrent existence before deletion and verifies API removal; it does not independently establish all downloaded data has disappeared from disk. -## Development roadmap +## Migration and rollback -- Validate PowerShell syntax and installer task-registration behavior on a clean Windows VM. -- Expand regression tests for additional edge cases. -- Verify qBittorrent 4.3.6 and newer versions separately. -- Add opt-in PIA integration only after VPN-interface binding and failure-mode tests. -- Consider deletion only with explicit opt-in, clear safeguards, and recovery procedures. +See [MIGRATION.md](MIGRATION.md). The candidate installer does not automatically disable any existing task or turn enforcement on. Preserve old scripts, credentials, task XML and state until the new installation has passed Windows QA. Do not copy personal logs/configuration into this repository. -## Privacy +Uninstall with `Uninstall-QbtGuard.ps1 -Destination ` and optional `-TaskName`. It removes only a task whose action matches that installation and retains installed files. Downloads are untouched. -No personal configuration, passwords, private logs, hostnames, local addresses, or paths from the original installation belong in this repository. The `.gitignore` excludes local credentials, configuration, and logs. Review all commits before making this repository public. +## QA status -## Contributing +The prior audit preview was exercised on one Windows installation. That evidence does not validate these new corrective actions. Windows CI parses all scripts and runs mocked Pester regressions. Clean-machine installation, real PIA failover, adapter/profile binding and qBittorrent restart are manual QA requirements. See [tests/README.md](tests/README.md). -Please provide reproducible bug reports with sanitized logs and version numbers. Do not post passwords, private IP addresses, share paths, or sensitive torrent filenames. +HTTP Web UI credentials are unencrypted in transit; use HTTPS or a trusted local connection. Do not expose the Web UI directly to the Internet. DPAPI files are tied to the installing Windows account and machine. ## License -qBtGuard is released under the [MIT License](LICENSE). You may use, copy, modify, and redistribute it, including commercially, provided the copyright and license notices are retained. +[MIT](LICENSE). diff --git a/Run-QbtGuard.vbs b/Run-QbtGuard.vbs index 167af5c..2a7538f 100644 --- a/Run-QbtGuard.vbs +++ b/Run-QbtGuard.vbs @@ -1,7 +1,8 @@ -Option Explicit -Dim shell, fso, scriptPath, command -Set shell = CreateObject("WScript.Shell") -Set fso = CreateObject("Scripting.FileSystemObject") -scriptPath = fso.BuildPath(fso.GetParentFolderName(WScript.ScriptFullName), "QbtGuard.ps1") -command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " & Chr(34) & scriptPath & Chr(34) -WScript.Quit shell.Run(command, 0, True) +Option Explicit +Dim shell, fso, scriptPath, command +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") +scriptPath = fso.BuildPath(fso.GetParentFolderName(WScript.ScriptFullName), "QbtGuard.ps1") +command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " & Chr(34) & scriptPath & Chr(34) +WScript.Quit shell.Run(command, 0, True) + diff --git a/Uninstall-QbtGuard.ps1 b/Uninstall-QbtGuard.ps1 index 7d34664..583240d 100644 --- a/Uninstall-QbtGuard.ps1 +++ b/Uninstall-QbtGuard.ps1 @@ -1,9 +1,9 @@ # qBtGuard 0.1.0-preview uninstaller. Retains installed files and logs. [CmdletBinding()] -param([string]$Destination = (Join-Path $env:LOCALAPPDATA 'qBtGuard')) +param([string]$Destination = (Join-Path $env:LOCALAPPDATA 'qBtGuard'), [string]$TaskName = 'qBtGuard Audit') Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$taskName = 'qBtGuard Audit' +$taskName = $TaskName $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if ($task) { $expectedLauncher = Join-Path ([IO.Path]::GetFullPath($Destination)) 'Run-QbtGuard.vbs' @@ -19,3 +19,4 @@ if ($task) { } if (Test-Path -LiteralPath $Destination) { Write-Host "Installation directory retained at $Destination for manual inspection/removal." } Write-Host 'Torrents and downloads untouched.' + diff --git a/config.example.json b/config.example.json index fcfafc8..ea16dcf 100644 --- a/config.example.json +++ b/config.example.json @@ -27,5 +27,22 @@ ".zipx" ], "AuditOnly": true, - "IntervalMinutes": 10 + "IntervalMinutes": 10, + "EnablePia": false, + "EnableVpnRecovery": false, + "EnablePortSync": false, + "EnableQbtRestart": false, + "DeleteProhibitedTorrents": false, + "DeleteInvalidMkvTorrents": false, + "RemoteEndpoint": false, + "PiaCtl": "C:\\Program Files\\Private Internet Access\\piactl.exe", + "VpnInterface": "", + "QbtExe": "C:\\Program Files\\qBittorrent\\qbittorrent.exe", + "QbtProcessName": "qbittorrent", + "QbtSettingsPath": "", + "PiaRegions": [ + "bulgaria", + "bolivia" + ], + "RegionCooldownMinutes": 10 } diff --git a/tests/QbtGuard.Api.Tests.ps1 b/tests/QbtGuard.Api.Tests.ps1 index 495634d..3ae95da 100644 --- a/tests/QbtGuard.Api.Tests.ps1 +++ b/tests/QbtGuard.Api.Tests.ps1 @@ -23,9 +23,9 @@ Describe 'qBtGuard mocked API behavior' { $ast = [System.Management.Automation.Language.Parser]::ParseFile($enginePath, [ref]$tokens, [ref]$errors) $errors.Count | Should Be 0 $requests = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -in @('Invoke-WebRequest','Invoke-RestMethod') }, $true)) - $requests.Count | Should Be 4 + $requests.Count | Should Be 3 foreach ($request in $requests) { - $request.CommandElements.Extent.Text -join ' ' | Should Match '-TimeoutSec\s+\d+' + $request.CommandElements.Extent.Text -join ' ' | Should Match '-TimeoutSec\s+\d+|@args' } } @@ -41,7 +41,7 @@ Describe 'qBtGuard mocked API behavior' { Mock Invoke-WebRequest { [pscustomobject]@{ Content='Fails.' } } Mock Invoke-RestMethod { throw 'Torrent API should not be called after failed login' } { & $engineScript -InstallPath $install } | Should Throw 'qBtGuard test: audit aborted' - (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: audit failed' + (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: cycle failed' Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It } @@ -49,7 +49,7 @@ Describe 'qBtGuard mocked API behavior' { Mock Invoke-WebRequest { throw 'Simulated connection refused' } Mock Invoke-RestMethod { throw 'Torrent API should not be called' } { & $engineScript -InstallPath $install } | Should Throw 'qBtGuard test: audit aborted' - (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: audit failed' + (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: cycle failed' Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It } @@ -59,7 +59,7 @@ Describe 'qBtGuard mocked API behavior' { Mock Invoke-RestMethod { throw 'Should not be reached' } { & $engineScript -InstallPath $install } | Should Throw 'qBtGuard test: audit aborted' $contents = Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw - $contents | Should Match 'ERROR: audit failed' + $contents | Should Match 'ERROR: cycle failed' $contents | Should Not Match $secret $contents | Should Not Match 'STACK:' } @@ -68,7 +68,62 @@ Describe 'qBtGuard mocked API behavior' { Mock Invoke-WebRequest { [pscustomobject]@{ Content='Ok.' } } Mock Invoke-RestMethod { throw 'Simulated torrent API timeout' } { & $engineScript -InstallPath $install } | Should Throw 'qBtGuard test: audit aborted' - (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: audit failed' + (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: cycle failed' Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope It } } + + +Describe 'Unified engine deletion and remote controls' { + BeforeEach { + $install=Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory $install | Out-Null + $config=@{QbtUrl='http://127.0.0.1:8080';QbtUser='test';AuditOnly=$true;BlockedExtensions=@('.exe');BlockNoExtension=$true;VerifyMkvSignatures=$true;DeleteProhibitedTorrents=$true} + ConvertTo-SecureString 'test-password' -AsPlainText -Force | ConvertFrom-SecureString | Set-Content (Join-Path $install 'credential.dpapi') + $hash='a' * 40 + $torrent=[pscustomobject]@{hash=$hash;name='fixture';progress=0;save_path=$install;content_path=''} + Mock Invoke-WebRequest { [pscustomobject]@{Content='Ok.'} } + } + It 'reports a prohibited file without deletion in audit mode' { + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + Mock Invoke-RestMethod { if ($Uri -match 'torrents/files') { @([pscustomobject]@{name='bad.exe';progress=0}) } elseif ($Uri -match 'torrents/info') { @($torrent) } } + & $engineScript -InstallPath $install + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/delete' } + Get-Content (Join-Path $install 'qbtguard.log') -Raw | Should Match 'WOULD delete torrent' + } + It 'deletes only the offending hash with downloaded data and verifies removal' { + $config.AuditOnly=$false + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + $script:removed=$false + Mock Invoke-RestMethod { + if ($Uri -match 'torrents/delete') { $script:removed=$true; return } + if ($Uri -match 'torrents/files') { return @([pscustomobject]@{name='bad.exe';progress=0}) } + if ($Uri -match 'torrents/info' -and -not $script:removed) { return @($torrent) } + return @() + } + & $engineScript -InstallPath $install + Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/delete' -and $Body.hashes -eq $hash -and $Body.deleteFiles -eq 'true' } + Get-Content (Join-Path $install 'qbtguard.log') -Raw | Should Match 'torrentsRemoved=1' + } + It 'does not delete when the deletion policy is disabled' { + $config.AuditOnly=$false; $config.DeleteProhibitedTorrents=$false + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + Mock Invoke-RestMethod { if ($Uri -match 'torrents/files') { @([pscustomobject]@{name='bad.exe';progress=0}) } elseif ($Uri -match 'torrents/info') { @($torrent) } } + & $engineScript -InstallPath $install + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/delete' } + } + It 'blocks local VPN control when configured for a remote endpoint' { + $config.RemoteEndpoint=$true; $config.EnablePia=$true + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + Mock Invoke-RestMethod { throw 'Must not contact API' } + { & $engineScript -InstallPath $install } | Should Throw + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It + } + It 'rejects a string in place of the audit boolean' { + $config.AuditOnly='false' + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + Mock Invoke-RestMethod { throw 'Must not contact API' } + { & $engineScript -InstallPath $install } | Should Throw + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It + } +} diff --git a/tests/QbtGuard.Control.Tests.ps1 b/tests/QbtGuard.Control.Tests.ps1 new file mode 100644 index 0000000..b4bb573 --- /dev/null +++ b/tests/QbtGuard.Control.Tests.ps1 @@ -0,0 +1,113 @@ +# Mocked controls: no VPN, process, or torrent changes are performed. +$engine = Join-Path (Split-Path -Parent $PSScriptRoot) 'QbtGuard.ps1' +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($engine,[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +foreach ($name in @('Get-Setting','Invoke-GuardAction','Test-LocalEndpoint','Test-VpnReady','Test-StartupBinding','Get-ForwardedPort','Invoke-VpnRecovery','Ensure-QbtRunning')) { + $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 + . ([scriptblock]::Create($definition.Extent.Text)) +} +# Stubs exist only so Pester can mock engine-owned functions without running the engine. +function Write-GuardLog { param($Message) } +function Invoke-Pia { param($Arguments) } +Describe 'Global action gate' { + It 'suppresses every action in audit mode' { + $auditOnly=$true; $script:executed=$false + Invoke-GuardAction 'test action' { $script:executed=$true } | Should Be $false + $script:executed | Should Be $false + } + It 'executes an action in enforcement mode' { + $auditOnly=$false; $script:executed=$false + Invoke-GuardAction 'test action' { $script:executed=$true } | Should Be $true + $script:executed | Should Be $true + } + It 'does not report success when an action fails' { + $auditOnly=$false + { Invoke-GuardAction 'test action' { throw 'failed' } } | Should Throw + } +} +Describe 'Forwarded port validation' { + It 'accepts a valid port' { Mock Invoke-Pia { '12345' }; Get-ForwardedPort | Should Be 12345 } + It 'rejects Failed' { Mock Invoke-Pia { 'Failed' }; Get-ForwardedPort | Should BeNullOrEmpty } + It 'rejects zero' { Mock Invoke-Pia { '0' }; Get-ForwardedPort | Should BeNullOrEmpty } + It 'rejects an out of range port' { Mock Invoke-Pia { '65536' }; Get-ForwardedPort | Should BeNullOrEmpty } +} +Describe 'qBittorrent restart safeguards' { + BeforeEach { + $c=[pscustomobject]@{EnableQbtRestart=$true;QbtProcessName='qbittorrent';QbtExe='test.exe'} + $auditOnly=$false + Mock Get-Process { $null } + Mock Start-Process { } + Mock Test-VpnReady { $true } + Mock Test-StartupBinding { $true } + Mock Test-Path { $true } + } + It 'does not restart when the VPN is unhealthy' { + Mock Test-VpnReady { $false } + { Ensure-QbtRunning } | Should Throw + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } + It 'does not restart when saved binding cannot be verified' { + Mock Test-StartupBinding { $false } + { Ensure-QbtRunning } | Should Throw + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } + It 'does not restart in audit mode' { + $auditOnly=$true + Ensure-QbtRunning | Should Be $false + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } + It 'respects disabled restart' { + $c.EnableQbtRestart=$false + Ensure-QbtRunning | Should Be $false + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } + It 'leaves an existing process running' { + Mock Get-Process { [pscustomobject]@{Id=123} } + Ensure-QbtRunning | Should Be $true + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } +} +Describe 'VPN recovery audit and cooldown' { + BeforeEach { + $InstallPath=Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory $InstallPath | Out-Null + $c=[pscustomobject]@{EnableVpnRecovery=$true;PiaRegions=@('region-a','region-b');RegionCooldownMinutes=10} + $auditOnly=$true + Mock Invoke-Pia { if ($Arguments[1] -eq 'connectionstate') { 'Reconnecting' } elseif ($Arguments[1] -eq 'portforward') { 'Failed' } elseif ($Arguments[1] -eq 'region') { 'region-a' } } + } + It 'plans recovery without changing PIA or cooldown state' { + Invoke-VpnRecovery + Test-Path (Join-Path $InstallPath 'region-state.json') | Should Be $false + Assert-MockCalled Invoke-Pia -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments[0] -in @('set','connect') } + } + It 'honors an existing recovery cooldown' { + $auditOnly=$false + @{LastAttemptUtc=[DateTimeOffset]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content (Join-Path $InstallPath 'region-state.json') + Invoke-VpnRecovery + Assert-MockCalled Invoke-Pia -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments[0] -in @('set','connect') } + } + It 'blocks recovery if the cooldown state is corrupt' { + 'invalid json' | Set-Content (Join-Path $InstallPath 'region-state.json') + { Invoke-VpnRecovery } | Should Throw + Assert-MockCalled Invoke-Pia -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments[0] -in @('set','connect') } + } + It 'rejects region IDs containing shell syntax' { + $c.PiaRegions=@('region-a','bad;command') + { Invoke-VpnRecovery } | Should Throw + } +} +Describe 'Saved startup binding' { + It 'requires the configured VPN interface in the selected profile' { + $path=Join-Path $TestDrive 'qbt.ini' + 'Connection\Interface=VPN adapter' | Set-Content $path + $c=[pscustomobject]@{QbtSettingsPath=$path;VpnInterface='VPN adapter'} + Test-StartupBinding | Should Be $true + } + It 'rejects the unrestricted interface' { + $path=Join-Path $TestDrive 'unbound.ini' + 'Connection\Interface=' | Set-Content $path + $c=[pscustomobject]@{QbtSettingsPath=$path;VpnInterface='VPN adapter'} + Test-StartupBinding | Should Be $false + } +} diff --git a/tests/QbtGuard.Tests.ps1 b/tests/QbtGuard.Tests.ps1 index 7a207ca..f82a89d 100644 --- a/tests/QbtGuard.Tests.ps1 +++ b/tests/QbtGuard.Tests.ps1 @@ -89,13 +89,3 @@ Describe 'qBtGuard torrent path validation' { } } -Describe 'qBtGuard read-only source safeguards' { - It 'contains no torrent mutation API endpoints' { - $source = Get-Content -LiteralPath $engine -Raw - $source | Should Not Match '/api/v2/torrents/(delete|add|pause|resume|recheck|renameFile|setLocation)' - } - It 'does not invoke filesystem deletion or PIA controls' { - $source = Get-Content -LiteralPath $engine -Raw - $source | Should Not Match '(?i)\b(Remove-Item|DeleteFile|Stop-Process|Start-Process|piactl)\b' - } -} diff --git a/tests/QbtGuard.Uninstall.Tests.ps1 b/tests/QbtGuard.Uninstall.Tests.ps1 index 0ec8497..a95ff18 100644 --- a/tests/QbtGuard.Uninstall.Tests.ps1 +++ b/tests/QbtGuard.Uninstall.Tests.ps1 @@ -39,3 +39,4 @@ Describe 'qBtGuard uninstaller ownership safeguards' { Assert-MockCalled Unregister-ScheduledTask -Times 0 -Exactly -Scope It } } + diff --git a/tests/README.md b/tests/README.md index 9cc4b39..02c095b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,12 +1,11 @@ -# Test plan (not yet automated) - -The preview is **not validated on other machines**. Before public release, test on a clean Windows 10/11 VM with qBittorrent 4.3.6 and Web UI API v2. - -1. Installation with and without a scheduled task; repeat installation; uninstallation. -2. Correct DPAPI authentication and wrong password, server unavailable, TLS failure. -3. Completed and incomplete torrents, nested directories, extensionless and prohibited files. -4. Valid EBML header, invalid header, unreadable file, missing UNC share. -5. Path traversal attempts, including absolute paths, dot-dot segments and alternate separators. -6. Scheduled task running under the intended user and while logged out. -7. Verify no delete, PIA, or qBittorrent lifecycle actions occur. -8. Scan all source and commit history for secrets before changing visibility to public. +# Windows QA + +Run in Windows PowerShell 5.1 with Pester 4.10.1: + +```powershell +Import-Module Pester -RequiredVersion 4.10.1; Invoke-Pester -Script .\tests +``` + +Tests use disposable local fixtures, DPAPI test credentials and mocked API/process/VPN/task commands. They never contact real qBittorrent or PIA. They cover path traversal and reparse points, header findings/privacy, API failure handling, shared audit action gating, deletion policy and hash targeting, disabled/unhealthy restart, remote-local separation, forwarding-port validation and recovery cooldown. + +CI checks syntax and runs these tests on Windows. Real VPN/interface/profile behavior, clean installation, scheduler registration and destructive policies still require isolated Windows integration QA using disposable downloads. A mocked test pass is not permission to deploy enforcement. From 0864bf7fb0ff2840f5fa69f9e899de5bdb99d572 Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:24:14 -0400 Subject: [PATCH 02/13] Pass PIA argument arrays explicitly and name HTTP splat clearly --- QbtGuard.ps1 | 24 ++++++++++++------------ tests/QbtGuard.Api.Tests.ps1 | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index f812201..1f16de3 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -46,8 +46,8 @@ function Test-LocalEndpoint([uri]$Address) { return ($resolved.Count -gt 0 -and @($resolved | Where-Object { $_ -notin $local }).Count -eq 0) } function Test-VpnReady { - if ((Invoke-Pia @('get','connectionstate')) -ne 'Connected') { return $false } - $vpnIP = Invoke-Pia @('get','vpnip') + if ((Invoke-Pia -Arguments @('get','connectionstate')) -ne 'Connected') { return $false } + $vpnIP = Invoke-Pia -Arguments @('get','vpnip') $ip = $null if (-not [Net.IPAddress]::TryParse($vpnIP, [ref]$ip)) { return $false } $addresses = @(Get-NetIPAddress -ErrorAction Stop | Where-Object { $_.IPAddress -eq $vpnIP }) @@ -67,18 +67,18 @@ function Test-StartupBinding { return ($binding -eq [string](Get-Setting 'VpnInterface' '') -and $binding -ne '') } function Invoke-QbtApi([string]$Endpoint, [string]$Method = 'Get', $Body = $null) { - $args = @{ Uri=($baseUrl + '/api/v2/' + $Endpoint); Method=$Method; WebSession=$session; Headers=$headers; TimeoutSec=30; ErrorAction='Stop' } - if ($null -ne $Body) { $args.Body = $Body } - Invoke-RestMethod @args + $requestArgs = @{ Uri=($baseUrl + '/api/v2/' + $Endpoint); Method=$Method; WebSession=$session; Headers=$headers; TimeoutSec=30; ErrorAction='Stop' } + if ($null -ne $Body) { $requestArgs.Body = $Body } + Invoke-RestMethod @requestArgs } function Get-ForwardedPort { - $output = Invoke-Pia @('get','portforward') + $output = Invoke-Pia -Arguments @('get','portforward') if ($output -match '^\d{1,5}$' -and [int]$output -ge 1 -and [int]$output -le 65535) { return [int]$output } return $null } function Invoke-VpnRecovery { - $state = Invoke-Pia @('get','connectionstate') - $portState = Invoke-Pia @('get','portforward') + $state = Invoke-Pia -Arguments @('get','connectionstate') + $portState = Invoke-Pia -Arguments @('get','portforward') if ($state -eq 'Connected' -and $portState -ne 'Failed') { return } if (-not (Get-Setting 'EnableVpnRecovery' $false)) { Write-GuardLog "NOTICE VPN recovery disabled; state=$state port=$portState"; return } $statePath = Join-Path $InstallPath 'region-state.json' @@ -91,18 +91,18 @@ function Invoke-VpnRecovery { $regions = @(Get-Setting 'PiaRegions' @()) if ($regions.Count -lt 2) { throw 'Configure at least two PIA region IDs.' } foreach ($region in $regions) { if ([string]$region -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') { throw 'Invalid PIA region ID.' } } - $current = Invoke-Pia @('get','region') + $current = Invoke-Pia -Arguments @('get','region') $index = [Array]::IndexOf($regions, $current) $next = [string]$regions[(($index + 1) % $regions.Count)] $changed = Invoke-GuardAction "switch PIA region to $next and reconnect" { @{LastAttemptUtc=[DateTimeOffset]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 - Invoke-Pia @('set','region',$next) | Out-Null - Invoke-Pia @('connect') | Out-Null + Invoke-Pia -Arguments @('set','region',$next) | Out-Null + Invoke-Pia -Arguments @('connect') | Out-Null } if (-not $changed) { return } $deadline = [DateTime]::UtcNow.AddSeconds(120) do { - if ((Invoke-Pia @('get','region')) -eq $next -and (Test-VpnReady) -and $null -ne (Get-ForwardedPort)) { Write-GuardLog 'VPN recovery verified'; return } + if ((Invoke-Pia -Arguments @('get','region')) -eq $next -and (Test-VpnReady) -and $null -ne (Get-ForwardedPort)) { Write-GuardLog 'VPN recovery verified'; return } Start-Sleep -Seconds 5 } while ([DateTime]::UtcNow -lt $deadline) throw 'VPN recovery did not become ready within 120 seconds.' diff --git a/tests/QbtGuard.Api.Tests.ps1 b/tests/QbtGuard.Api.Tests.ps1 index 3ae95da..8fd3074 100644 --- a/tests/QbtGuard.Api.Tests.ps1 +++ b/tests/QbtGuard.Api.Tests.ps1 @@ -25,7 +25,7 @@ Describe 'qBtGuard mocked API behavior' { $requests = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -in @('Invoke-WebRequest','Invoke-RestMethod') }, $true)) $requests.Count | Should Be 3 foreach ($request in $requests) { - $request.CommandElements.Extent.Text -join ' ' | Should Match '-TimeoutSec\s+\d+|@args' + $request.CommandElements.Extent.Text -join ' ' | Should Match '-TimeoutSec\s+\d+|@requestArgs' } } From 6790bae54dbf57c979a446cf23a93459882bd63d Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:25:15 -0400 Subject: [PATCH 03/13] Use Windows built-in Pester 3.4 to avoid gallery installation during QA --- .github/workflows/windows-tests.yml | 3 +-- tests/README.md | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 3d966f0..c375014 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -12,7 +12,6 @@ jobs: run: | $ErrorActionPreference = 'Stop' Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object { $tokens=$null; $errors=$null; $null=[Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$tokens,[ref]$errors); if ($errors.Count) { throw ($errors | Out-String) } } - Install-Module Pester -RequiredVersion 4.10.1 -Force -Scope CurrentUser -SkipPublisherCheck - Import-Module Pester -RequiredVersion 4.10.1 + Import-Module Pester -RequiredVersion 3.4.0 $result = Invoke-Pester -Script ./tests -PassThru if ($result.FailedCount -gt 0) { throw 'Regression tests failed.' } diff --git a/tests/README.md b/tests/README.md index 02c095b..bedb941 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,9 +1,9 @@ # Windows QA -Run in Windows PowerShell 5.1 with Pester 4.10.1: +Run in Windows PowerShell 5.1 with Pester 3.4.0: ```powershell -Import-Module Pester -RequiredVersion 4.10.1; Invoke-Pester -Script .\tests +Import-Module Pester -RequiredVersion 3.4.0; Invoke-Pester -Script .\tests ``` Tests use disposable local fixtures, DPAPI test credentials and mocked API/process/VPN/task commands. They never contact real qBittorrent or PIA. They cover path traversal and reparse points, header findings/privacy, API failure handling, shared audit action gating, deletion policy and hash targeting, disabled/unhealthy restart, remote-local separation, forwarding-port validation and recovery cooldown. From 328dc9a8df263573df30835927cd07369ad2b0e0 Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:46:14 -0400 Subject: [PATCH 04/13] Distinguish PIA public exit IP from local tunnel readiness --- QbtGuard.ps1 | 17 +++++++++------ README.md | 2 +- tests/QbtGuard.Control.Tests.ps1 | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 1f16de3..77536ba 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -47,14 +47,17 @@ function Test-LocalEndpoint([uri]$Address) { } function Test-VpnReady { if ((Invoke-Pia -Arguments @('get','connectionstate')) -ne 'Connected') { return $false } - $vpnIP = Invoke-Pia -Arguments @('get','vpnip') - $ip = $null - if (-not [Net.IPAddress]::TryParse($vpnIP, [ref]$ip)) { return $false } - $addresses = @(Get-NetIPAddress -ErrorAction Stop | Where-Object { $_.IPAddress -eq $vpnIP }) - if ($addresses.Count -ne 1) { return $false } - $adapter = Get-NetAdapter -InterfaceIndex $addresses[0].InterfaceIndex -ErrorAction Stop + # PIA vpnip is the public exit IP, not the adapter's private tunnel address. + $exitIP = $null + if (-not [Net.IPAddress]::TryParse((Invoke-Pia -Arguments @('get','vpnip')), [ref]$exitIP)) { return $false } + if ([Net.IPAddress]::IsLoopback($exitIP) -or $exitIP.Equals([Net.IPAddress]::Any) -or $exitIP.Equals([Net.IPAddress]::IPv6Any)) { return $false } $expected = [string](Get-Setting 'VpnInterface' '') - return ($adapter.Status -eq 'Up' -and -not [string]::IsNullOrWhiteSpace($expected) -and $adapter.Name -eq $expected) + if ([string]::IsNullOrWhiteSpace($expected)) { return $false } + $adapters = @(Get-NetAdapter -ErrorAction Stop | Where-Object { $_.Name -eq $expected }) + if ($adapters.Count -ne 1 -or $adapters[0].Status -ne 'Up') { return $false } + $addresses = @(Get-NetIPAddress -InterfaceIndex $adapters[0].InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $_.AddressState -eq 'Preferred' -and $_.IPAddress -notlike '169.254.*' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -ne '0.0.0.0' }) + return ($addresses.Count -gt 0) } function Test-StartupBinding { # Read the intended qBittorrent profile before allowing a process start. diff --git a/README.md b/README.md index 5896055..11e50f5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Set `AuditOnly: false` only after QA and enable each desired action separately. | `VerifyMkvSignatures` | Inspect completed local MKV headers | | `DeleteProhibitedTorrents`, `DeleteInvalidMkvTorrents` | Separate opt-in deletion policies | -PIA integration is optional. With it enabled, the API endpoint must resolve exclusively to this machine. The tunnel must report Connected, have a local VPN IP on the configured Up adapter, and qBittorrent must report the configured interface binding. qBtGuard is **not a kill switch**: configure PIA's kill switch and qBittorrent's interface binding independently. It does not stop an already running qBittorrent if the tunnel fails. Recovery can take up to 120 seconds; the scheduler prevents concurrent scheduled instances and the engine locks each installation against concurrent manual runs. +PIA integration is optional. With it enabled, the API endpoint must resolve exclusively to this machine. The tunnel must report Connected, report a valid public exit IP and have a Preferred, non-link-local IPv4 address on the configured Up adapter, and qBittorrent must report the configured interface binding. qBtGuard is **not a kill switch**: configure PIA's kill switch and qBittorrent's interface binding independently. It does not stop an already running qBittorrent if the tunnel fails. Recovery can take up to 120 seconds; the scheduler prevents concurrent scheduled instances and the engine locks each installation against concurrent manual runs. The first compatibility target is qBittorrent 4.3.6. Profile/interface naming, PIA behavior, x86/x64 paths and other versions require Windows validation. No user's addresses, credentials, logs, or preferred region list are embedded in the engine. diff --git a/tests/QbtGuard.Control.Tests.ps1 b/tests/QbtGuard.Control.Tests.ps1 index b4bb573..15c9ab5 100644 --- a/tests/QbtGuard.Control.Tests.ps1 +++ b/tests/QbtGuard.Control.Tests.ps1 @@ -111,3 +111,40 @@ Describe 'Saved startup binding' { Test-StartupBinding | Should Be $false } } + +Describe 'VPN exit IP and local tunnel readiness' { + BeforeEach { + $c=[pscustomobject]@{VpnInterface='test-tunnel'} + Mock Invoke-Pia { if ($Arguments[1] -eq 'connectionstate') { 'Connected' } else { '203.0.113.8' } } + Mock Get-NetAdapter { @([pscustomobject]@{Name='test-tunnel';Status='Up';InterfaceIndex=42}) } + Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='10.8.0.2';AddressState='Preferred'}) } + } + It 'accepts a private tunnel IP different from the public VPN exit IP' { + Test-VpnReady | Should Be $true + Assert-MockCalled Get-NetIPAddress -Times 1 -Exactly -Scope It -ParameterFilter { $InterfaceIndex -eq 42 -and $AddressFamily -eq 'IPv4' } + } + It 'rejects a disconnected VPN even when the adapter is up' { + Mock Invoke-Pia { 'Reconnecting' } + Test-VpnReady | Should Be $false + } + It 'rejects a down configured adapter' { + Mock Get-NetAdapter { @([pscustomobject]@{Name='test-tunnel';Status='Disconnected';InterfaceIndex=42}) } + Test-VpnReady | Should Be $false + } + It 'rejects a missing configured adapter' { + Mock Get-NetAdapter { @() } + Test-VpnReady | Should Be $false + } + It 'rejects an adapter with only a link local address' { + Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='169.254.1.2';AddressState='Preferred'}) } + Test-VpnReady | Should Be $false + } + It 'rejects an unready tunnel address' { + Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='10.8.0.2';AddressState='Tentative'}) } + Test-VpnReady | Should Be $false + } + It 'rejects an invalid PIA exit address' { + Mock Invoke-Pia { if ($Arguments[1] -eq 'connectionstate') { 'Connected' } else { 'Unknown' } } + Test-VpnReady | Should Be $false + } +} From a69a2e0c61e2ef334c528aab516bf3c932491304 Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:30:00 -0400 Subject: [PATCH 05/13] Validate Windows qBittorrent interface names separately from internal IDs --- QbtGuard.ps1 | 30 ++++++++++++++++++++++-------- README.md | 4 ++-- tests/QbtGuard.Control.Tests.ps1 | 31 ++++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 77536ba..215a265 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -59,15 +59,29 @@ function Test-VpnReady { Where-Object { $_.AddressState -eq 'Preferred' -and $_.IPAddress -notlike '169.254.*' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -ne '0.0.0.0' }) return ($addresses.Count -gt 0) } -function Test-StartupBinding { - # Read the intended qBittorrent profile before allowing a process start. +function Get-SavedBinding { $path = [string](Get-Setting 'QbtSettingsPath' '') - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false } + $expected = [string](Get-Setting 'VpnInterface' '') + if ([string]::IsNullOrWhiteSpace($expected) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } $ini = Get-Content -LiteralPath $path - $bindings = @($ini | Where-Object { $_ -match '^Connection\\Interface=' }) - if ($bindings.Count -ne 1) { return $false } - $binding = ($bindings[0] -split '=',2)[1].Trim() - return ($binding -eq [string](Get-Setting 'VpnInterface' '') -and $binding -ne '') + $ids = @($ini | Where-Object { $_ -match '^Connection\\Interface=' }) + $names = @($ini | Where-Object { $_ -match '^Connection\\InterfaceName=' }) + if ($ids.Count -ne 1 -or $names.Count -gt 1) { return $null } + $id = ($ids[0] -split '=',2)[1].Trim() + if ([string]::IsNullOrWhiteSpace($id)) { return $null } + # Windows qBittorrent saves a display name separately from its internal ID. + $name = $id + if ($names.Count -eq 1) { $name = ($names[0] -split '=',2)[1].Trim() } + if ($name -ne $expected) { return $null } + return [pscustomobject]@{ Name=$name; Id=$id } +} +function Test-StartupBinding { + return ($null -ne (Get-SavedBinding)) +} +function Test-LiveBinding($Preferences) { + $saved = Get-SavedBinding + if ($null -eq $saved) { return $false } + return ([string]$Preferences.current_network_interface -eq $saved.Id) } function Invoke-QbtApi([string]$Endpoint, [string]$Method = 'Get', $Body = $null) { $requestArgs = @{ Uri=($baseUrl + '/api/v2/' + $Endpoint); Method=$Method; WebSession=$session; Headers=$headers; TimeoutSec=30; ErrorAction='Stop' } @@ -225,7 +239,7 @@ try { $stage = 'preferences' if ($piaEnabled) { $prefs = Invoke-QbtApi 'app/preferences' - if ([string]$prefs.current_network_interface -ne [string](Get-Setting 'VpnInterface' '')) { throw 'Live qBittorrent VPN interface binding mismatch.' } + if (-not (Test-LiveBinding $prefs)) { throw 'Live qBittorrent VPN interface binding mismatch.' } if (Get-Setting 'EnablePortSync' $false) { $port = Get-ForwardedPort if ($null -ne $port -and [int]$prefs.listen_port -ne $port) { diff --git a/README.md b/README.md index 11e50f5..56a546f 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Set `AuditOnly: false` only after QA and enable each desired action separately. | `EnableVpnRecovery` | Rotate configured PIA regions after disconnection or explicit forwarding failure | | `EnablePortSync` | Apply and verify PIA's forwarded port | | `EnableQbtRestart` | Start a missing local process after VPN and saved profile binding checks | -| `VpnInterface` | Exact local VPN adapter name; must also match the live qBittorrent interface preference | -| `QbtSettingsPath` | Intended user's qBittorrent INI file; restart requires its `Connection\Interface` value to match | +| `VpnInterface` | Exact local VPN adapter name; must match the saved display name; the live qBittorrent binding is checked against its saved internal ID | +| `QbtSettingsPath` | Intended user's qBittorrent INI file; restart requires its saved interface name (or legacy alias) to match; the live API must match the saved internal ID | | `PiaCtl`, `QbtExe`, `QbtProcessName` | Local installation paths and process name | | `PiaRegions`, `RegionCooldownMinutes` | User-selected region IDs and persistent recovery cooldown | | `BlockNoExtension`, `BlockedExtensions` | File-name inspection policy | diff --git a/tests/QbtGuard.Control.Tests.ps1 b/tests/QbtGuard.Control.Tests.ps1 index 15c9ab5..aea2384 100644 --- a/tests/QbtGuard.Control.Tests.ps1 +++ b/tests/QbtGuard.Control.Tests.ps1 @@ -3,7 +3,7 @@ $engine = Join-Path (Split-Path -Parent $PSScriptRoot) 'QbtGuard.ps1' $tokens=$null; $errors=$null $ast=[Management.Automation.Language.Parser]::ParseFile($engine,[ref]$tokens,[ref]$errors) if ($errors.Count) { throw ($errors | Out-String) } -foreach ($name in @('Get-Setting','Invoke-GuardAction','Test-LocalEndpoint','Test-VpnReady','Test-StartupBinding','Get-ForwardedPort','Invoke-VpnRecovery','Ensure-QbtRunning')) { +foreach ($name in @('Get-Setting','Invoke-GuardAction','Test-LocalEndpoint','Test-VpnReady','Get-SavedBinding','Test-StartupBinding','Test-LiveBinding','Get-ForwardedPort','Invoke-VpnRecovery','Ensure-QbtRunning')) { $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 . ([scriptblock]::Create($definition.Extent.Text)) } @@ -148,3 +148,32 @@ Describe 'VPN exit IP and local tunnel readiness' { Test-VpnReady | Should Be $false } } + +Describe 'Windows qBittorrent interface name and ID' { + BeforeEach { + $path=Join-Path $TestDrive 'windows-binding.ini' + @('Connection\InterfaceName=test-tunnel','Connection\Interface=iftype53_12345') | Set-Content $path + $c=[pscustomobject]@{QbtSettingsPath=$path;VpnInterface='test-tunnel'} + } + It 'accepts a matching saved display name with a distinct internal ID' { + Test-StartupBinding | Should Be $true + } + It 'compares the live API binding to the saved internal ID' { + Test-LiveBinding ([pscustomobject]@{current_network_interface='iftype53_12345'}) | Should Be $true + } + It 'rejects an unrelated live internal ID' { + Test-LiveBinding ([pscustomobject]@{current_network_interface='iftype53_99999'}) | Should Be $false + } + It 'rejects a saved name that does not match the configured adapter' { + $c.VpnInterface='different-tunnel' + Test-StartupBinding | Should Be $false + } + It 'rejects a name without an internal ID' { + 'Connection\InterfaceName=test-tunnel' | Set-Content $path + Test-StartupBinding | Should Be $false + } + It 'rejects duplicate binding names' { + 'Connection\InterfaceName=another-tunnel' | Add-Content $path + Test-StartupBinding | Should Be $false + } +} From fbadd6c1956a057c04c1c141999fbc652e78fda6 Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:26:58 -0400 Subject: [PATCH 06/13] Keep log messages out of control-function return values --- QbtGuard.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 215a265..daa28e5 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -8,7 +8,8 @@ $log = Join-Path $InstallPath 'qbtguard.log' function Write-GuardLog([string]$Message) { $clean = $Message -replace '[\r\n\x00-\x1f]', ' ' Add-Content -LiteralPath $log -Value ('{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $clean) - Write-Output $clean + # Logging must not contaminate boolean function return values. + Write-Host $clean } function Get-Setting([string]$Name, $Default) { $property = $c.PSObject.Properties[$Name] From b8a06214abc00f854b8f67ee84cf5af01976e35a Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:26:59 -0400 Subject: [PATCH 07/13] Test real logger with audit and enforcement return values --- tests/QbtGuard.Logging.Tests.ps1 | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/QbtGuard.Logging.Tests.ps1 diff --git a/tests/QbtGuard.Logging.Tests.ps1 b/tests/QbtGuard.Logging.Tests.ps1 new file mode 100644 index 0000000..c3c93a9 --- /dev/null +++ b/tests/QbtGuard.Logging.Tests.ps1 @@ -0,0 +1,30 @@ +# Exercise production logging, not the mock used by isolated control tests. +$engine=Join-Path (Split-Path -Parent $PSScriptRoot) 'QbtGuard.ps1' +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($engine,[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +Describe 'Production logger preserves control return values' { + BeforeEach { + foreach ($name in @('Write-GuardLog','Invoke-GuardAction')) { + $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 + . ([scriptblock]::Create($definition.Extent.Text)) + } + $log=Join-Path $TestDrive 'control.log' + } + It 'returns exactly one false value when audit mode logs a proposed action' { + $auditOnly=$true + $values=@(Invoke-GuardAction 'fixture' { throw 'Must not run' }) + $values.Count | Should Be 1 + $values[0] | Should Be $false + (Get-Content $log -Raw) | Should Match 'WOULD fixture' + } + It 'returns exactly one true value when enforcement logs success' { + $auditOnly=$false + $values=@(Invoke-GuardAction 'fixture' { 'discard action output' }) + $values.Count | Should Be 1 + $values[0] | Should Be $true + } + It 'does not return a success-stream value from logging alone' { + @(Write-GuardLog 'fixture').Count | Should Be 0 + } +} From 354223614ad6f167f93f2200d8c6b10b7001315d Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:18:18 -0400 Subject: [PATCH 08/13] Use local tunnel readiness instead of public exit-IP discovery as VPN gate --- QbtGuard.ps1 | 17 ++++++++------- README.md | 6 +++++- tests/QbtGuard.Control.Tests.ps1 | 37 ++++++++++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index daa28e5..6b0f9fb 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -47,18 +47,19 @@ function Test-LocalEndpoint([uri]$Address) { return ($resolved.Count -gt 0 -and @($resolved | Where-Object { $_ -notin $local }).Count -eq 0) } function Test-VpnReady { - if ((Invoke-Pia -Arguments @('get','connectionstate')) -ne 'Connected') { return $false } - # PIA vpnip is the public exit IP, not the adapter's private tunnel address. - $exitIP = $null - if (-not [Net.IPAddress]::TryParse((Invoke-Pia -Arguments @('get','vpnip')), [ref]$exitIP)) { return $false } - if ([Net.IPAddress]::IsLoopback($exitIP) -or $exitIP.Equals([Net.IPAddress]::Any) -or $exitIP.Equals([Net.IPAddress]::IPv6Any)) { return $false } + $state = Invoke-Pia -Arguments @('get','connectionstate') + if ($state -ne 'Connected') { Write-GuardLog "VPN BLOCKED: PIA connection state is $state"; return $false } + # Public exit-IP discovery is telemetry, not the local tunnel readiness gate. + # PIA can return Unknown for vpnip while Connected with a usable tunnel. $expected = [string](Get-Setting 'VpnInterface' '') - if ([string]::IsNullOrWhiteSpace($expected)) { return $false } + if ([string]::IsNullOrWhiteSpace($expected)) { Write-GuardLog 'VPN BLOCKED: VpnInterface is not configured'; return $false } $adapters = @(Get-NetAdapter -ErrorAction Stop | Where-Object { $_.Name -eq $expected }) - if ($adapters.Count -ne 1 -or $adapters[0].Status -ne 'Up') { return $false } + if ($adapters.Count -ne 1) { Write-GuardLog 'VPN BLOCKED: configured tunnel adapter was not uniquely found'; return $false } + if ($adapters[0].Status -ne 'Up') { Write-GuardLog 'VPN BLOCKED: configured tunnel adapter is not Up'; return $false } $addresses = @(Get-NetIPAddress -InterfaceIndex $adapters[0].InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.AddressState -eq 'Preferred' -and $_.IPAddress -notlike '169.254.*' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -ne '0.0.0.0' }) - return ($addresses.Count -gt 0) + if ($addresses.Count -eq 0) { Write-GuardLog 'VPN BLOCKED: no usable Preferred IPv4 address on the configured tunnel'; return $false } + return $true } function Get-SavedBinding { $path = [string](Get-Setting 'QbtSettingsPath' '') diff --git a/README.md b/README.md index 56a546f..7dd2255 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Set `AuditOnly: false` only after QA and enable each desired action separately. | `VerifyMkvSignatures` | Inspect completed local MKV headers | | `DeleteProhibitedTorrents`, `DeleteInvalidMkvTorrents` | Separate opt-in deletion policies | -PIA integration is optional. With it enabled, the API endpoint must resolve exclusively to this machine. The tunnel must report Connected, report a valid public exit IP and have a Preferred, non-link-local IPv4 address on the configured Up adapter, and qBittorrent must report the configured interface binding. qBtGuard is **not a kill switch**: configure PIA's kill switch and qBittorrent's interface binding independently. It does not stop an already running qBittorrent if the tunnel fails. Recovery can take up to 120 seconds; the scheduler prevents concurrent scheduled instances and the engine locks each installation against concurrent manual runs. +PIA integration is optional. With it enabled, the API endpoint must resolve exclusively to this machine. The tunnel must report Connected, have a Preferred, non-link-local IPv4 address on the configured Up adapter, and qBittorrent must report the configured interface binding. qBtGuard is **not a kill switch**: configure PIA's kill switch and qBittorrent's interface binding independently. It does not stop an already running qBittorrent if the tunnel fails. Recovery can take up to 120 seconds; the scheduler prevents concurrent scheduled instances and the engine locks each installation against concurrent manual runs. The first compatibility target is qBittorrent 4.3.6. Profile/interface naming, PIA behavior, x86/x64 paths and other versions require Windows validation. No user's addresses, credentials, logs, or preferred region list are embedded in the engine. @@ -59,3 +59,7 @@ HTTP Web UI credentials are unencrypted in transit; use HTTPS or a trusted local ## License [MIT](LICENSE). + +### PIA public-IP discovery + +The VPN readiness gate uses PIA's Connected state and the configured Up adapter with a usable Preferred local IPv4 address. The saved and live qBittorrent interface bindings remain separately checked. Public exit-IP discovery (`piactl get vpnip`) is not a gate: a live integration test observed `Unknown` alongside Connected, a usable WireGuard tunnel and an assigned forwarded port. This change avoids blocking port synchronization on unavailable address telemetry; it does not independently prove end-to-end Internet reachability. PIA CLI failures and unavailable local tunnel checks still fail the cycle. VPN BLOCKED log messages identify unsuccessful readiness checks. diff --git a/tests/QbtGuard.Control.Tests.ps1 b/tests/QbtGuard.Control.Tests.ps1 index aea2384..9eb2f17 100644 --- a/tests/QbtGuard.Control.Tests.ps1 +++ b/tests/QbtGuard.Control.Tests.ps1 @@ -143,9 +143,10 @@ Describe 'VPN exit IP and local tunnel readiness' { Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='10.8.0.2';AddressState='Tentative'}) } Test-VpnReady | Should Be $false } - It 'rejects an invalid PIA exit address' { + It 'accepts healthy tunnel checks when public exit IP telemetry would be Unknown' { Mock Invoke-Pia { if ($Arguments[1] -eq 'connectionstate') { 'Connected' } else { 'Unknown' } } - Test-VpnReady | Should Be $false + Test-VpnReady | Should Be $true + Assert-MockCalled Invoke-Pia -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments[1] -eq 'vpnip' } } } @@ -177,3 +178,35 @@ Describe 'Windows qBittorrent interface name and ID' { Test-StartupBinding | Should Be $false } } + +Describe 'VPN readiness fail-closed diagnostics' { + BeforeEach { + $c=[pscustomobject]@{VpnInterface='test-tunnel'} + Mock Write-GuardLog {} + Mock Invoke-Pia { 'Connected' } + Mock Get-NetAdapter { @([pscustomobject]@{Name='test-tunnel';Status='Up';InterfaceIndex=42}) } + Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='10.8.0.2';AddressState='Preferred'}) } + } + It 'reports a disconnected VPN and blocks readiness' { + Mock Invoke-Pia { 'Reconnecting' } + Test-VpnReady | Should Be $false + Assert-MockCalled Write-GuardLog -Times 1 -Exactly -Scope It -ParameterFilter { $Message -match 'connection state is Reconnecting' } + } + It 'blocks readiness when the configured interface is empty' { + $c.VpnInterface='' + Test-VpnReady | Should Be $false + Assert-MockCalled Get-NetAdapter -Times 0 -Exactly -Scope It + } + It 'blocks readiness when only an unrelated Up adapter exists' { + Mock Get-NetAdapter { @([pscustomobject]@{Name='Ethernet';Status='Up';InterfaceIndex=2}) } + Test-VpnReady | Should Be $false + } + It 'fails when local adapter inspection throws' { + Mock Get-NetAdapter { throw 'adapter inspection failed' } + { Test-VpnReady } | Should Throw + } + It 'fails when local address inspection throws' { + Mock Get-NetIPAddress { throw 'address inspection failed' } + { Test-VpnReady } | Should Throw + } +} From 5f91a8bf30dd4cee2d1724c1484838c2868c2f35 Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:27:58 -0400 Subject: [PATCH 09/13] Wait for PIA disconnect completion and retry unfulfilled connect requests --- QbtGuard.ps1 | 40 ++++++++++--- README.md | 6 ++ tests/QbtGuard.Recovery.Tests.ps1 | 99 +++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tests/QbtGuard.Recovery.Tests.ps1 diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 6b0f9fb..0dbfee9 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -113,19 +113,45 @@ function Invoke-VpnRecovery { $current = Invoke-Pia -Arguments @('get','region') $index = [Array]::IndexOf($regions, $current) $next = [string]$regions[(($index + 1) % $regions.Count)] - $changed = Invoke-GuardAction "switch PIA region to $next and reconnect" { + $changed = Invoke-GuardAction "prepare PIA region recovery to $next" { @{LastAttemptUtc=[DateTimeOffset]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + # A successful CLI exit acknowledges a request, not a completed state change. + $before = Invoke-Pia -Arguments @('get','connectionstate') + if ($before -notin @('Disconnected','Disconnecting')) { + Invoke-Pia -Arguments @('disconnect') | Out-Null + } + $settled = $false + for ($poll = 0; $poll -lt 15; $poll++) { + if ((Invoke-Pia -Arguments @('get','connectionstate')) -eq 'Disconnected') { $settled = $true; break } + Start-Sleep -Seconds 2 + } + if (-not $settled) { throw 'PIA did not finish disconnecting; region and connect requests withheld.' } Invoke-Pia -Arguments @('set','region',$next) | Out-Null - Invoke-Pia -Arguments @('connect') | Out-Null } if (-not $changed) { return } - $deadline = [DateTime]::UtcNow.AddSeconds(120) - do { - if ((Invoke-Pia -Arguments @('get','region')) -eq $next -and (Test-VpnReady) -and $null -ne (Get-ForwardedPort)) { Write-GuardLog 'VPN recovery verified'; return } + # Retry acknowledged connect requests only while fully Disconnected. + # Never issue connect during Disconnecting or interrupt Connecting. + $connectAttempts = 0; $nextConnectPoll = 0 + for ($poll = 0; $poll -lt 24; $poll++) { + $state = Invoke-Pia -Arguments @('get','connectionstate') + if ($state -eq 'Disconnected' -and $connectAttempts -lt 4 -and $poll -ge $nextConnectPoll) { + $requested = Invoke-GuardAction 'request PIA connect' { + Invoke-Pia -Arguments @('connect') | Out-Null + } + if (-not $requested) { return } + $connectAttempts++ + $nextConnectPoll = $poll + 3 + Write-GuardLog "PIA connect request $connectAttempts issued; awaiting Connected state" + } + if ($state -eq 'Connected' -and (Invoke-Pia -Arguments @('get','region')) -eq $next -and (Test-VpnReady) -and $null -ne (Get-ForwardedPort)) { + Write-GuardLog 'VPN recovery verified' + return + } Start-Sleep -Seconds 5 - } while ([DateTime]::UtcNow -lt $deadline) - throw 'VPN recovery did not become ready within 120 seconds.' + } + throw 'VPN recovery timed out after bounded polling; no verified tunnel and forwarded port. Manual reconnect may be required.' } + function Ensure-QbtRunning { $processName = [string](Get-Setting 'QbtProcessName' 'qbittorrent') if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } diff --git a/README.md b/README.md index 7dd2255..2b32ffa 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,9 @@ HTTP Web UI credentials are unencrypted in transit; use HTTPS or a trusted local ### PIA public-IP discovery The VPN readiness gate uses PIA's Connected state and the configured Up adapter with a usable Preferred local IPv4 address. The saved and live qBittorrent interface bindings remain separately checked. Public exit-IP discovery (`piactl get vpnip`) is not a gate: a live integration test observed `Unknown` alongside Connected, a usable WireGuard tunnel and an assigned forwarded port. This change avoids blocking port synchronization on unavailable address telemetry; it does not independently prove end-to-end Internet reachability. PIA CLI failures and unavailable local tunnel checks still fail the cycle. VPN BLOCKED log messages identify unsuccessful readiness checks. + +### Asynchronous PIA recovery + +The recovery action waits up to 30 seconds for Disconnected before changing region. It then polls for a connected tunnel in the selected region and an assigned forwarded port. Connect requests are sent only from Disconnected, at least 15 seconds apart, with a maximum of four requests during 24 five-second polling intervals. A CLI success exit is treated as request acknowledgment, not proof of connection. Native-command overhead adds to the polling duration. Disconnect settling timeouts withhold the region/connect commands, and failures remain visible in the log. Audit mode performs no disconnects, region changes, connect requests or recovery waits. + +The live recovery test on the initial system required a manual reconnect and therefore did not establish unattended recovery. Subsequent live testing must have an independent local reconnect fallback prepared before disconnecting the VPN; the kill switch can block the chat connection. Keep production protection restored between tests. diff --git a/tests/QbtGuard.Recovery.Tests.ps1 b/tests/QbtGuard.Recovery.Tests.ps1 new file mode 100644 index 0000000..a4e71e7 --- /dev/null +++ b/tests/QbtGuard.Recovery.Tests.ps1 @@ -0,0 +1,99 @@ +# Mocked asynchronous PIA state transitions; no real VPN calls or waits. +$engine=Join-Path (Split-Path -Parent $PSScriptRoot) 'QbtGuard.ps1' +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($engine,[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +function Invoke-Pia { param($Arguments) } +function Write-GuardLog { param($Message) } +function Test-VpnReady { } +function Get-ForwardedPort { } +Describe 'Asynchronous PIA recovery' { + BeforeEach { + foreach ($name in @('Get-Setting','Invoke-GuardAction','Invoke-VpnRecovery')) { + $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 + . ([scriptblock]::Create($definition.Extent.Text)) + } + $InstallPath=Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory $InstallPath | Out-Null + $c=[pscustomobject]@{EnableVpnRecovery=$true;PiaRegions=@('region-a','region-b');RegionCooldownMinutes=10} + $auditOnly=$false + $script:piaState='Disconnecting' + $script:piaRegion='region-a' + $script:stateReads=0 + $script:settleAt=4 + $script:connectCount=0 + $script:connectSucceedsAt=1 + $script:commandStates=New-Object 'System.Collections.Generic.List[string]' + Mock Start-Sleep {} + Mock Write-GuardLog {} + Mock Test-VpnReady { $true } + Mock Get-ForwardedPort { 12345 } + Mock Invoke-Pia { + if ($Arguments[0] -eq 'get') { + switch ($Arguments[1]) { + 'connectionstate' { + $script:stateReads++ + if ($script:piaState -eq 'Disconnecting' -and $script:stateReads -ge $script:settleAt) { $script:piaState='Disconnected' } + return $script:piaState + } + 'portforward' { return 'Failed' } + 'region' { return $script:piaRegion } + } + } + if ($Arguments[0] -eq 'disconnect') { + $script:commandStates.Add('disconnect:'+$script:piaState) + $script:piaState='Disconnecting'; return + } + if ($Arguments[0] -eq 'set') { + $script:commandStates.Add('set:'+$script:piaState) + $script:piaRegion=$Arguments[2]; return + } + if ($Arguments[0] -eq 'connect') { + $script:commandStates.Add('connect:'+$script:piaState) + $script:connectCount++ + if ($script:connectCount -ge $script:connectSucceedsAt) { $script:piaState='Connected' } + } + } + } + It 'waits for Disconnected before setting the region and connecting' { + Invoke-VpnRecovery + ($script:commandStates -join ',') | Should Be 'set:Disconnected,connect:Disconnected' + $script:piaRegion | Should Be 'region-b' + Assert-MockCalled Write-GuardLog -Times 1 -Exactly -Scope It -ParameterFilter { $Message -eq 'VPN recovery verified' } + } + It 'retries a connect request that returns success but leaves PIA Disconnected' { + $script:connectSucceedsAt=2 + Invoke-VpnRecovery + $script:connectCount | Should Be 2 + ($script:commandStates -join ',') | Should Not Match 'connect:Disconnecting' + } + It 'withholds region and connect commands if disconnect never settles' { + $script:settleAt=9999 + { Invoke-VpnRecovery } | Should Throw 'did not finish disconnecting' + $script:commandStates.Count | Should Be 0 + } + It 'explicitly disconnects a connected tunnel after forwarding failure' { + $script:piaState='Connected' + Invoke-VpnRecovery + $script:commandStates[0] | Should Be 'disconnect:Connected' + $script:commandStates[1] | Should Be 'set:Disconnected' + } + It 'bounds connect retries and reports a timeout without success' { + $script:connectSucceedsAt=9999 + { Invoke-VpnRecovery } | Should Throw 'timed out' + $script:connectCount | Should Be 4 + Assert-MockCalled Write-GuardLog -Times 0 -Exactly -Scope It -ParameterFilter { $Message -eq 'VPN recovery verified' } + } + It 'does not declare recovery verified without a forwarded port' { + Mock Get-ForwardedPort { $null } + { Invoke-VpnRecovery } | Should Throw 'timed out' + Assert-MockCalled Write-GuardLog -Times 0 -Exactly -Scope It -ParameterFilter { $Message -eq 'VPN recovery verified' } + } + It 'does not mutate PIA or wait in audit mode' { + $auditOnly=$true + Invoke-VpnRecovery + $script:commandStates.Count | Should Be 0 + Assert-MockCalled Start-Sleep -Times 0 -Exactly -Scope It + Test-Path (Join-Path $InstallPath 'region-state.json') | Should Be $false + } +} From a1972ce788f75846fdfc01f90243dc62c2f195d7 Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:49:35 -0400 Subject: [PATCH 10/13] Add one-torrent scope for isolated inspection and enforcement tests --- QbtGuard.ps1 | 345 ++++++++++++++++++++++++++++++++++- README.md | 4 + tests/QbtGuard.Api.Tests.ps1 | 52 ++++++ 3 files changed, 400 insertions(+), 1 deletion(-) diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 0dbfee9..2d9a0d6 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -1,6 +1,349 @@ # qBtGuard 0.2.0 candidate: unified audit and opt-in enforcement. [CmdletBinding()] -param([string]$InstallPath) +param( + [string]$InstallPath, + [ValidatePattern('^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64}) +if ([string]::IsNullOrWhiteSpace($InstallPath)) { $InstallPath = Split-Path -Parent $PSCommandPath } +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$log = Join-Path $InstallPath 'qbtguard.log' +function Write-GuardLog([string]$Message) { + $clean = $Message -replace '[\r\n\x00-\x1f]', ' ' + Add-Content -LiteralPath $log -Value ('{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $clean) + # Logging must not contaminate boolean function return values. + Write-Host $clean +} +function Get-Setting([string]$Name, $Default) { + $property = $c.PSObject.Properties[$Name] + if ($null -eq $property) { return $Default } + return $property.Value +} +function Invoke-GuardAction([string]$Name, [scriptblock]$Action) { + if ($auditOnly) { Write-GuardLog "WOULD $Name"; return $false } + & $Action | Out-Null + Write-GuardLog "ACTION $Name" + return $true +} +function Invoke-Pia([string[]]$Arguments) { + # Use ProcessStartInfo for bounded execution and explicit exit status. + # All arguments come from fixed verbs or validated region IDs. + $si = New-Object Diagnostics.ProcessStartInfo + $si.FileName = [string](Get-Setting 'PiaCtl' '') + $si.Arguments = $Arguments -join ' ' + $si.UseShellExecute = $false; $si.CreateNoWindow = $true + $si.RedirectStandardOutput = $true; $si.RedirectStandardError = $true + $process = New-Object Diagnostics.Process + $process.StartInfo = $si + try { + if (-not $process.Start()) { throw 'PIA command did not start.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(10000)) { $process.Kill(); throw 'PIA command timed out.' } + if ($process.ExitCode -ne 0) { throw 'PIA command failed.' } + return $stdout.Result.Trim() + } finally { $process.Dispose() } +} +function Test-LocalEndpoint([uri]$Address) { + $local = @('127.0.0.1','::1') + @(Get-NetIPAddress | Select-Object -ExpandProperty IPAddress) + $resolved = @([Net.Dns]::GetHostAddresses($Address.DnsSafeHost) | ForEach-Object { $_.ToString() }) + return ($resolved.Count -gt 0 -and @($resolved | Where-Object { $_ -notin $local }).Count -eq 0) +} +function Test-VpnReady { + $state = Invoke-Pia -Arguments @('get','connectionstate') + if ($state -ne 'Connected') { Write-GuardLog "VPN BLOCKED: PIA connection state is $state"; return $false } + # Public exit-IP discovery is telemetry, not the local tunnel readiness gate. + # PIA can return Unknown for vpnip while Connected with a usable tunnel. + $expected = [string](Get-Setting 'VpnInterface' '') + if ([string]::IsNullOrWhiteSpace($expected)) { Write-GuardLog 'VPN BLOCKED: VpnInterface is not configured'; return $false } + $adapters = @(Get-NetAdapter -ErrorAction Stop | Where-Object { $_.Name -eq $expected }) + if ($adapters.Count -ne 1) { Write-GuardLog 'VPN BLOCKED: configured tunnel adapter was not uniquely found'; return $false } + if ($adapters[0].Status -ne 'Up') { Write-GuardLog 'VPN BLOCKED: configured tunnel adapter is not Up'; return $false } + $addresses = @(Get-NetIPAddress -InterfaceIndex $adapters[0].InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $_.AddressState -eq 'Preferred' -and $_.IPAddress -notlike '169.254.*' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -ne '0.0.0.0' }) + if ($addresses.Count -eq 0) { Write-GuardLog 'VPN BLOCKED: no usable Preferred IPv4 address on the configured tunnel'; return $false } + return $true +} +function Get-SavedBinding { + $path = [string](Get-Setting 'QbtSettingsPath' '') + $expected = [string](Get-Setting 'VpnInterface' '') + if ([string]::IsNullOrWhiteSpace($expected) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } + $ini = Get-Content -LiteralPath $path + $ids = @($ini | Where-Object { $_ -match '^Connection\\Interface=' }) + $names = @($ini | Where-Object { $_ -match '^Connection\\InterfaceName=' }) + if ($ids.Count -ne 1 -or $names.Count -gt 1) { return $null } + $id = ($ids[0] -split '=',2)[1].Trim() + if ([string]::IsNullOrWhiteSpace($id)) { return $null } + # Windows qBittorrent saves a display name separately from its internal ID. + $name = $id + if ($names.Count -eq 1) { $name = ($names[0] -split '=',2)[1].Trim() } + if ($name -ne $expected) { return $null } + return [pscustomobject]@{ Name=$name; Id=$id } +} +function Test-StartupBinding { + return ($null -ne (Get-SavedBinding)) +} +function Test-LiveBinding($Preferences) { + $saved = Get-SavedBinding + if ($null -eq $saved) { return $false } + return ([string]$Preferences.current_network_interface -eq $saved.Id) +} +function Invoke-QbtApi([string]$Endpoint, [string]$Method = 'Get', $Body = $null) { + $requestArgs = @{ Uri=($baseUrl + '/api/v2/' + $Endpoint); Method=$Method; WebSession=$session; Headers=$headers; TimeoutSec=30; ErrorAction='Stop' } + if ($null -ne $Body) { $requestArgs.Body = $Body } + Invoke-RestMethod @requestArgs +} +function Get-ForwardedPort { + $output = Invoke-Pia -Arguments @('get','portforward') + if ($output -match '^\d{1,5}$' -and [int]$output -ge 1 -and [int]$output -le 65535) { return [int]$output } + return $null +} +function Invoke-VpnRecovery { + $state = Invoke-Pia -Arguments @('get','connectionstate') + $portState = Invoke-Pia -Arguments @('get','portforward') + if ($state -eq 'Connected' -and $portState -ne 'Failed') { return } + if (-not (Get-Setting 'EnableVpnRecovery' $false)) { Write-GuardLog "NOTICE VPN recovery disabled; state=$state port=$portState"; return } + $statePath = Join-Path $InstallPath 'region-state.json' + if (Test-Path -LiteralPath $statePath) { + # A corrupt cooldown record blocks recovery rather than causing rapid rotation. + $last = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json + $elapsed = ([DateTimeOffset]::UtcNow - [DateTimeOffset]::Parse($last.LastAttemptUtc)).TotalMinutes + if ($elapsed -lt [int](Get-Setting 'RegionCooldownMinutes' 10)) { Write-GuardLog 'NOTICE VPN recovery cooldown active'; return } + } + $regions = @(Get-Setting 'PiaRegions' @()) + if ($regions.Count -lt 2) { throw 'Configure at least two PIA region IDs.' } + foreach ($region in $regions) { if ([string]$region -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') { throw 'Invalid PIA region ID.' } } + $current = Invoke-Pia -Arguments @('get','region') + $index = [Array]::IndexOf($regions, $current) + $next = [string]$regions[(($index + 1) % $regions.Count)] + $changed = Invoke-GuardAction "prepare PIA region recovery to $next" { + @{LastAttemptUtc=[DateTimeOffset]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + # A successful CLI exit acknowledges a request, not a completed state change. + $before = Invoke-Pia -Arguments @('get','connectionstate') + if ($before -notin @('Disconnected','Disconnecting')) { + Invoke-Pia -Arguments @('disconnect') | Out-Null + } + $settled = $false + for ($poll = 0; $poll -lt 15; $poll++) { + if ((Invoke-Pia -Arguments @('get','connectionstate')) -eq 'Disconnected') { $settled = $true; break } + Start-Sleep -Seconds 2 + } + if (-not $settled) { throw 'PIA did not finish disconnecting; region and connect requests withheld.' } + Invoke-Pia -Arguments @('set','region',$next) | Out-Null + } + if (-not $changed) { return } + # Retry acknowledged connect requests only while fully Disconnected. + # Never issue connect during Disconnecting or interrupt Connecting. + $connectAttempts = 0; $nextConnectPoll = 0 + for ($poll = 0; $poll -lt 24; $poll++) { + $state = Invoke-Pia -Arguments @('get','connectionstate') + if ($state -eq 'Disconnected' -and $connectAttempts -lt 4 -and $poll -ge $nextConnectPoll) { + $requested = Invoke-GuardAction 'request PIA connect' { + Invoke-Pia -Arguments @('connect') | Out-Null + } + if (-not $requested) { return } + $connectAttempts++ + $nextConnectPoll = $poll + 3 + Write-GuardLog "PIA connect request $connectAttempts issued; awaiting Connected state" + } + if ($state -eq 'Connected' -and (Invoke-Pia -Arguments @('get','region')) -eq $next -and (Test-VpnReady) -and $null -ne (Get-ForwardedPort)) { + Write-GuardLog 'VPN recovery verified' + return + } + Start-Sleep -Seconds 5 + } + throw 'VPN recovery timed out after bounded polling; no verified tunnel and forwarded port. Manual reconnect may be required.' +} + +function Ensure-QbtRunning { + $processName = [string](Get-Setting 'QbtProcessName' 'qbittorrent') + if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } + if (-not (Get-Setting 'EnableQbtRestart' $false)) { Write-GuardLog 'NOTICE qBittorrent absent; restart disabled'; return $false } + if (-not (Test-VpnReady) -or -not (Test-StartupBinding)) { throw 'Restart blocked: VPN readiness or saved interface binding not verified.' } + $exe = [string](Get-Setting 'QbtExe' '') + if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw 'qBittorrent executable missing.' } + $started = Invoke-GuardAction 'restart qBittorrent' { + if (-not (Test-VpnReady)) { throw 'VPN changed before restart.' } + Start-Process -FilePath $exe -WindowStyle Minimized | Out-Null + } + if (-not $started) { return $false } + $deadline = [DateTime]::UtcNow.AddSeconds(60) + do { + if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } + Start-Sleep -Seconds 2 + } while ([DateTime]::UtcNow -lt $deadline) + throw 'qBittorrent process restart timed out.' +} +function Get-MkvIssue([string]$Path) { + $stream = $null + try { + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) + $bytes = New-Object byte[] 12 + $count = $stream.Read($bytes, 0, 12) + if ($count -lt 4) { return 'Too short for EBML signature' } + if ($bytes[0] -eq 0x1a -and $bytes[1] -eq 0x45 -and $bytes[2] -eq 0xdf -and $bytes[3] -eq 0xa3) { return $null } + if ($bytes[0] -eq 0x4d -and $bytes[1] -eq 0x5a) { return 'Windows MZ executable header' } + return ('Not an EBML header: ' + [BitConverter]::ToString($bytes, 0, [Math]::Min($count, 12))) + } catch { return 'UNREADABLE: file could not be opened or read' } + finally { if ($null -ne $stream) { $stream.Dispose() } } +} +function Test-NoReparsePoint([string]$Root, [string]$Candidate) { + # Reject links/junctions at any component, including the save directory itself. + # Fail closed if a path component cannot be inspected. + try { + $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd([char]92, [char]47) + $candidateFull = [IO.Path]::GetFullPath($Candidate) + $prefix = $rootFull + [IO.Path]::DirectorySeparatorChar + if (-not $candidateFull.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $false } + $walk = $candidateFull + while ($walk) { + $item = Get-Item -LiteralPath $walk -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $false } + if ($walk.TrimEnd([char]92, [char]47) -ieq $rootFull) { return $true } + $parent = [IO.Path]::GetDirectoryName($walk.TrimEnd([char]92, [char]47)) + if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $walk) { return $false } + $walk = $parent + } + } catch { return $false } + return $false +} +function Resolve-TorrentFile($Torrent, [string]$Name) { + if ([IO.Path]::IsPathRooted($Name) -or $Name -match '(^|[\\/])\.\.([\\/]|$)' -or $Name -match '^[a-zA-Z]:') { return $null } + $root = [string]$Torrent.save_path + if ([string]::IsNullOrWhiteSpace($root)) { return $null } + $rootFull = [IO.Path]::GetFullPath($root).TrimEnd([char]92) + [char]92 + $candidate = [IO.Path]::GetFullPath((Join-Path $root ($Name -replace '/', '\'))) + if (-not $candidate.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { return $null } + if ((Test-Path -LiteralPath $candidate -PathType Leaf) -and (Test-NoReparsePoint $root $candidate)) { return $candidate } + $content = [string]$Torrent.content_path + if (-not [string]::IsNullOrWhiteSpace($content) -and [IO.Path]::GetFileName($content) -ieq [IO.Path]::GetFileName($Name) -and (Test-Path -LiteralPath $content -PathType Leaf)) { + $full = [IO.Path]::GetFullPath($content) + if ($full.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase) -and (Test-NoReparsePoint $root $full)) { return $full } + } + return $null +} + +$session = $null; $headers = $null; $uri = $null; $lock = $null +$loggedIn = $false +$stage = 'configuration' +try { + # Exclusive lock covers manual and scheduled runs from this installation. + try { $lock = [IO.File]::Open((Join-Path $InstallPath 'run.lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } + catch [IO.IOException] { Write-Output 'qBtGuard already running or lock unavailable; cycle skipped.'; return } + $c = Get-Content -LiteralPath (Join-Path $InstallPath 'config.json') -Raw | ConvertFrom-Json + $auditOnly = Get-Setting 'AuditOnly' $true + if ($auditOnly -isnot [bool]) { throw 'AuditOnly must be a JSON boolean.' } + foreach ($name in @('EnablePia','EnableVpnRecovery','EnablePortSync','EnableQbtRestart','DeleteProhibitedTorrents','DeleteInvalidMkvTorrents','BlockNoExtension','VerifyMkvSignatures','RemoteEndpoint')) { + if ((Get-Setting $name $false) -isnot [bool]) { throw "Invalid boolean setting: $name" } + } + $uri = [uri]([string]$c.QbtUrl) + if (-not $uri.IsAbsoluteUri -or $uri.Scheme -notin @('http','https') -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) { throw 'Invalid Web UI URL.' } + $baseUrl = $uri.AbsoluteUri.TrimEnd('/') + $piaEnabled = Get-Setting 'EnablePia' $false + $remote = Get-Setting 'RemoteEndpoint' $false + if ($remote -and ($piaEnabled -or (Get-Setting 'EnableQbtRestart' $false))) { throw 'Remote endpoint cannot use local PIA or watchdog controls.' } + if ((Get-Setting 'EnableQbtRestart' $false) -and -not $piaEnabled) { throw 'Restart requires PIA integration.' } + if (-not $piaEnabled -and ((Get-Setting 'EnableVpnRecovery' $false) -or (Get-Setting 'EnablePortSync' $false))) { throw 'VPN recovery and port sync require PIA integration.' } + if ($piaEnabled) { + $stage = 'VPN' + if (-not (Test-LocalEndpoint $uri)) { throw 'PIA endpoint must resolve exclusively to this computer.' } + Invoke-VpnRecovery + if (-not (Test-VpnReady)) { throw 'VPN and configured interface not ready; cycle blocked.' } + if (-not (Ensure-QbtRunning)) { return } + } + $stage = 'authentication' + $secure = (Get-Content -LiteralPath (Join-Path $InstallPath 'credential.dpapi') -Raw).Trim() | ConvertTo-SecureString + $credential = New-Object System.Management.Automation.PSCredential([string]$c.QbtUser, $secure) + $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession + $headers = @{ Referer=($baseUrl + '/'); Origin=$uri.GetLeftPart([UriPartial]::Authority) } + $deadline = [DateTime]::UtcNow.AddSeconds(60) + do { + try { + $login = Invoke-WebRequest -Uri ($baseUrl + '/api/v2/auth/login') -Method Post -WebSession $session -Headers $headers -Body @{username=[string]$c.QbtUser; password=$credential.GetNetworkCredential().Password} -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop + if ($login.Content.Trim() -ne 'Ok.') { throw 'Authentication rejected.' } + $loggedIn = $true + } catch { + if (-not $piaEnabled -or [DateTime]::UtcNow -ge $deadline) { throw } + Start-Sleep -Seconds 2 + } + } until ($loggedIn) + $stage = 'preferences' + if ($piaEnabled) { + $prefs = Invoke-QbtApi 'app/preferences' + if (-not (Test-LiveBinding $prefs)) { throw 'Live qBittorrent VPN interface binding mismatch.' } + if (Get-Setting 'EnablePortSync' $false) { + $port = Get-ForwardedPort + if ($null -ne $port -and [int]$prefs.listen_port -ne $port) { + $changed = Invoke-GuardAction "set qBittorrent listening port to $port" { + if (-not (Test-VpnReady) -or (Get-ForwardedPort) -ne $port) { throw 'VPN or port changed before synchronization.' } + Invoke-QbtApi 'app/setPreferences' 'Post' @{json=(@{listen_port=$port} | ConvertTo-Json -Compress)} | Out-Null + } + if ($changed -and [int](Invoke-QbtApi 'app/preferences').listen_port -ne $port) { throw 'Port change verification failed.' } + } elseif ($null -eq $port) { Write-GuardLog 'NOTICE no valid PIA forwarded port available' } + } + } + $stage = 'file scan' + $infoEndpoint = 'torrents/info' + if ($OnlyTorrentHash) { $infoEndpoint += '?hashes=' + $OnlyTorrentHash } + $torrents = @(Invoke-QbtApi $infoEndpoint | ForEach-Object { $_ }) + if ($OnlyTorrentHash) { + # Enforce scope locally even if a server ignores its hashes query filter. + $torrents = @($torrents | Where-Object { $null -ne $_ -and $_.PSObject.Properties['hash'] -and [string]$_.hash -eq $OnlyTorrentHash }) + Write-GuardLog ("SCOPE requested hash=$OnlyTorrentHash matched=" + $torrents.Count) + } + $blocked = @($c.BlockedExtensions | ForEach-Object { ([string]$_).ToLowerInvariant() }) + $checked = 0; $findings = 0; $mkvChecked = 0; $mkvSkipped = 0; $mkvUnreadable = 0; $deleted = 0 + foreach ($torrent in $torrents) { + if ($null -eq $torrent -or [string]$torrent.hash -notmatch '^[a-fA-F0-9]{40,64}$') { throw 'Invalid torrent identity returned by API.' } + $hash = [string]$torrent.hash + $files = @(Invoke-QbtApi ('torrents/files?hash=' + $hash) | ForEach-Object { $_ }) + $prohibited = $false; $invalidMkv = $false + foreach ($file in $files) { + if ($null -eq $file) { continue } + $checked++ + $name = [string]$file.name + $ext = [IO.Path]::GetExtension($name).ToLowerInvariant() + if (((Get-Setting 'BlockNoExtension' $true) -and [string]::IsNullOrWhiteSpace($ext)) -or $ext -in $blocked) { + $prohibited = $true; $findings++ + Write-GuardLog ('ALERT prohibited file: torrent="{0}" file="{1}"' -f $torrent.name, $name) + } + if ((Get-Setting 'VerifyMkvSignatures' $true) -and $ext -eq '.mkv') { + if ([double]$torrent.progress -lt 1 -or [double]$file.progress -lt 1 -or $remote) { $mkvSkipped++; continue } + $path = Resolve-TorrentFile $torrent $name + if (-not $path) { $mkvSkipped++; Write-GuardLog ('NOTICE MKV unavailable: file="{0}"' -f $name); continue } + $mkvChecked++ + $issue = Get-MkvIssue $path + if ($issue) { + $findings++ + if ($issue -like 'UNREADABLE:*') { $mkvUnreadable++ } else { $invalidMkv = $true } + Write-GuardLog ('ALERT MKV signature: file="{0}" issue="{1}"' -f $name, $issue) + } + } + } + if (($prohibited -and (Get-Setting 'DeleteProhibitedTorrents' $false)) -or ($invalidMkv -and (Get-Setting 'DeleteInvalidMkvTorrents' $false))) { + $didDelete = Invoke-GuardAction "delete torrent $hash AND downloaded data" { + # Recheck existence before deletion; never call direct filesystem deletion. + $present = @(Invoke-QbtApi ('torrents/info?hashes=' + $hash) | ForEach-Object { $_ }) + if ($present.Count -ne 1 -or [string]$present[0].hash -ne $hash) { throw 'Torrent changed or disappeared before deletion.' } + Invoke-QbtApi 'torrents/delete' 'Post' @{hashes=$hash; deleteFiles='true'} | Out-Null + if (@(Invoke-QbtApi ('torrents/info?hashes=' + $hash) | ForEach-Object { $_ }).Count -ne 0) { throw 'Torrent removal not verified.' } + } + if ($didDelete) { $deleted++ } + } + } + $mode = 'ENFORCE'; if ($auditOnly) { $mode = 'AUDIT' } + Write-GuardLog "$mode completed: files=$checked findings=$findings mkvChecked=$mkvChecked mkvSkipped=$mkvSkipped mkvUnreadable=$mkvUnreadable torrentsRemoved=$deleted" +} catch { + Write-GuardLog ('ERROR: cycle failed at ' + $stage + ' (' + $_.Exception.GetType().Name + '). No successful completion recorded.') + exit 1 +} finally { + if ($loggedIn) { + try { Invoke-WebRequest -Uri ($baseUrl + '/api/v2/auth/logout') -Method Post -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | Out-Null } catch {} + } + if ($null -ne $lock) { $lock.Dispose() } +} +)] + [string]$OnlyTorrentHash +) if ([string]::IsNullOrWhiteSpace($InstallPath)) { $InstallPath = Split-Path -Parent $PSCommandPath } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' diff --git a/README.md b/README.md index 2b32ffa..23b7d14 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,7 @@ The VPN readiness gate uses PIA's Connected state and the configured Up adapter The recovery action waits up to 30 seconds for Disconnected before changing region. It then polls for a connected tunnel in the selected region and an assigned forwarded port. Connect requests are sent only from Disconnected, at least 15 seconds apart, with a maximum of four requests during 24 five-second polling intervals. A CLI success exit is treated as request acknowledgment, not proof of connection. Native-command overhead adds to the polling duration. Disconnect settling timeouts withhold the region/connect commands, and failures remain visible in the log. Audit mode performs no disconnects, region changes, connect requests or recovery waits. The live recovery test on the initial system required a manual reconnect and therefore did not establish unattended recovery. Subsequent live testing must have an independent local reconnect fallback prepared before disconnecting the VPN; the kill switch can block the chat connection. Keep production protection restored between tests. + +### One-torrent inspection and enforcement + +Use `QbtGuard.ps1 -OnlyTorrentHash ` to restrict file inspection and torrent deletion to one 40- or 64-character hexadecimal hash. The server query is filtered and the returned torrent list is filtered again locally. An absent target produces a scope message with zero matches and no torrent deletion. This option does not enable enforcement or override the configuration. VPN recovery, restart and port synchronization policies remain independent: disable those policies when doing a deletion-only fixture test. Omit the option for normal whole-queue operation. diff --git a/tests/QbtGuard.Api.Tests.ps1 b/tests/QbtGuard.Api.Tests.ps1 index 8fd3074..dd3fcd1 100644 --- a/tests/QbtGuard.Api.Tests.ps1 +++ b/tests/QbtGuard.Api.Tests.ps1 @@ -127,3 +127,55 @@ Describe 'Unified engine deletion and remote controls' { Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It } } + +Describe 'Single torrent scope' { + BeforeEach { + $install=Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory $install | Out-Null + $targetHash='a' * 40; $otherHash='b' * 40 + $target=[pscustomobject]@{hash=$targetHash;name='target';progress=0;save_path=$install;content_path=''} + $other=[pscustomobject]@{hash=$otherHash;name='unrelated';progress=0;save_path=$install;content_path=''} + @{QbtUrl='http://127.0.0.1:8080';QbtUser='test';AuditOnly=$true;BlockedExtensions=@('.exe');BlockNoExtension=$true;VerifyMkvSignatures=$false;DeleteProhibitedTorrents=$true} | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + ConvertTo-SecureString 'test-password' -AsPlainText -Force | ConvertFrom-SecureString | Set-Content (Join-Path $install 'credential.dpapi') + Mock Invoke-WebRequest { [pscustomobject]@{Content='Ok.'} } + } + It 'ignores unrelated torrents even when the API ignores the hash filter' { + Mock Invoke-RestMethod { + if ($Uri -match 'torrents/info') { return @($target,$other) } + if ($Uri -match 'torrents/files') { return @([pscustomobject]@{name='fixture.exe';progress=0}) } + } + & $engineScript -InstallPath $install -OnlyTorrentHash $targetHash + Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -match ('torrents/files\?hash='+$targetHash) } + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match ('torrents/files\?hash='+$otherHash) } + } + It 'does not inspect or delete any torrent when the requested hash is absent' { + Mock Invoke-RestMethod { if ($Uri -match 'torrents/info') { return @($other) }; throw 'Must not scan files' } + & $engineScript -InstallPath $install -OnlyTorrentHash $targetHash + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/(files|delete)' } + Get-Content (Join-Path $install 'qbtguard.log') -Raw | Should Match 'matched=0' + } + It 'rejects malformed hashes before contacting the API' { + Mock Invoke-RestMethod {} + { & $engineScript -InstallPath $install -OnlyTorrentHash 'invalid;hash' } | Should Throw + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It + } + It 'enforces deletion only for the requested torrent' { + $cfg=Get-Content (Join-Path $install 'config.json') -Raw | ConvertFrom-Json + $cfg.AuditOnly=$false + $cfg | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + $script:scopeRemoved=$false; $script:scopeInfoReads=0 + Mock Invoke-RestMethod { + if ($Uri -match 'torrents/info') { + $script:scopeInfoReads++ + if ($script:scopeInfoReads -eq 1) { return @($target,$other) } + if (-not $script:scopeRemoved) { return @($target) } + return @() + } + if ($Uri -match 'torrents/files') { return @([pscustomobject]@{name='fixture.exe';progress=0}) } + if ($Uri -match 'torrents/delete') { $script:scopeRemoved=$true } + } + & $engineScript -InstallPath $install -OnlyTorrentHash $targetHash + Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/delete' -and $Body.hashes -eq $targetHash -and $Body.deleteFiles -eq 'true' } + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match ('torrents/files\?hash='+$otherHash) } + } +} From d01ed720d2ffcbf7f7c9250daf2b094235ea50c1 Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:50:23 -0400 Subject: [PATCH 11/13] Correct scope parameter syntax in generated source --- QbtGuard.ps1 | 338 +-------------------------------------------------- 1 file changed, 3 insertions(+), 335 deletions(-) diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 2d9a0d6..9669204 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -2,7 +2,9 @@ [CmdletBinding()] param( [string]$InstallPath, - [ValidatePattern('^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64}) + [ValidatePattern('^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$')] + [string]$OnlyTorrentHash +) if ([string]::IsNullOrWhiteSpace($InstallPath)) { $InstallPath = Split-Path -Parent $PSCommandPath } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -341,337 +343,3 @@ try { } if ($null -ne $lock) { $lock.Dispose() } } -)] - [string]$OnlyTorrentHash -) -if ([string]::IsNullOrWhiteSpace($InstallPath)) { $InstallPath = Split-Path -Parent $PSCommandPath } -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -$log = Join-Path $InstallPath 'qbtguard.log' -function Write-GuardLog([string]$Message) { - $clean = $Message -replace '[\r\n\x00-\x1f]', ' ' - Add-Content -LiteralPath $log -Value ('{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $clean) - # Logging must not contaminate boolean function return values. - Write-Host $clean -} -function Get-Setting([string]$Name, $Default) { - $property = $c.PSObject.Properties[$Name] - if ($null -eq $property) { return $Default } - return $property.Value -} -function Invoke-GuardAction([string]$Name, [scriptblock]$Action) { - if ($auditOnly) { Write-GuardLog "WOULD $Name"; return $false } - & $Action | Out-Null - Write-GuardLog "ACTION $Name" - return $true -} -function Invoke-Pia([string[]]$Arguments) { - # Use ProcessStartInfo for bounded execution and explicit exit status. - # All arguments come from fixed verbs or validated region IDs. - $si = New-Object Diagnostics.ProcessStartInfo - $si.FileName = [string](Get-Setting 'PiaCtl' '') - $si.Arguments = $Arguments -join ' ' - $si.UseShellExecute = $false; $si.CreateNoWindow = $true - $si.RedirectStandardOutput = $true; $si.RedirectStandardError = $true - $process = New-Object Diagnostics.Process - $process.StartInfo = $si - try { - if (-not $process.Start()) { throw 'PIA command did not start.' } - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit(10000)) { $process.Kill(); throw 'PIA command timed out.' } - if ($process.ExitCode -ne 0) { throw 'PIA command failed.' } - return $stdout.Result.Trim() - } finally { $process.Dispose() } -} -function Test-LocalEndpoint([uri]$Address) { - $local = @('127.0.0.1','::1') + @(Get-NetIPAddress | Select-Object -ExpandProperty IPAddress) - $resolved = @([Net.Dns]::GetHostAddresses($Address.DnsSafeHost) | ForEach-Object { $_.ToString() }) - return ($resolved.Count -gt 0 -and @($resolved | Where-Object { $_ -notin $local }).Count -eq 0) -} -function Test-VpnReady { - $state = Invoke-Pia -Arguments @('get','connectionstate') - if ($state -ne 'Connected') { Write-GuardLog "VPN BLOCKED: PIA connection state is $state"; return $false } - # Public exit-IP discovery is telemetry, not the local tunnel readiness gate. - # PIA can return Unknown for vpnip while Connected with a usable tunnel. - $expected = [string](Get-Setting 'VpnInterface' '') - if ([string]::IsNullOrWhiteSpace($expected)) { Write-GuardLog 'VPN BLOCKED: VpnInterface is not configured'; return $false } - $adapters = @(Get-NetAdapter -ErrorAction Stop | Where-Object { $_.Name -eq $expected }) - if ($adapters.Count -ne 1) { Write-GuardLog 'VPN BLOCKED: configured tunnel adapter was not uniquely found'; return $false } - if ($adapters[0].Status -ne 'Up') { Write-GuardLog 'VPN BLOCKED: configured tunnel adapter is not Up'; return $false } - $addresses = @(Get-NetIPAddress -InterfaceIndex $adapters[0].InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop | - Where-Object { $_.AddressState -eq 'Preferred' -and $_.IPAddress -notlike '169.254.*' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -ne '0.0.0.0' }) - if ($addresses.Count -eq 0) { Write-GuardLog 'VPN BLOCKED: no usable Preferred IPv4 address on the configured tunnel'; return $false } - return $true -} -function Get-SavedBinding { - $path = [string](Get-Setting 'QbtSettingsPath' '') - $expected = [string](Get-Setting 'VpnInterface' '') - if ([string]::IsNullOrWhiteSpace($expected) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } - $ini = Get-Content -LiteralPath $path - $ids = @($ini | Where-Object { $_ -match '^Connection\\Interface=' }) - $names = @($ini | Where-Object { $_ -match '^Connection\\InterfaceName=' }) - if ($ids.Count -ne 1 -or $names.Count -gt 1) { return $null } - $id = ($ids[0] -split '=',2)[1].Trim() - if ([string]::IsNullOrWhiteSpace($id)) { return $null } - # Windows qBittorrent saves a display name separately from its internal ID. - $name = $id - if ($names.Count -eq 1) { $name = ($names[0] -split '=',2)[1].Trim() } - if ($name -ne $expected) { return $null } - return [pscustomobject]@{ Name=$name; Id=$id } -} -function Test-StartupBinding { - return ($null -ne (Get-SavedBinding)) -} -function Test-LiveBinding($Preferences) { - $saved = Get-SavedBinding - if ($null -eq $saved) { return $false } - return ([string]$Preferences.current_network_interface -eq $saved.Id) -} -function Invoke-QbtApi([string]$Endpoint, [string]$Method = 'Get', $Body = $null) { - $requestArgs = @{ Uri=($baseUrl + '/api/v2/' + $Endpoint); Method=$Method; WebSession=$session; Headers=$headers; TimeoutSec=30; ErrorAction='Stop' } - if ($null -ne $Body) { $requestArgs.Body = $Body } - Invoke-RestMethod @requestArgs -} -function Get-ForwardedPort { - $output = Invoke-Pia -Arguments @('get','portforward') - if ($output -match '^\d{1,5}$' -and [int]$output -ge 1 -and [int]$output -le 65535) { return [int]$output } - return $null -} -function Invoke-VpnRecovery { - $state = Invoke-Pia -Arguments @('get','connectionstate') - $portState = Invoke-Pia -Arguments @('get','portforward') - if ($state -eq 'Connected' -and $portState -ne 'Failed') { return } - if (-not (Get-Setting 'EnableVpnRecovery' $false)) { Write-GuardLog "NOTICE VPN recovery disabled; state=$state port=$portState"; return } - $statePath = Join-Path $InstallPath 'region-state.json' - if (Test-Path -LiteralPath $statePath) { - # A corrupt cooldown record blocks recovery rather than causing rapid rotation. - $last = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json - $elapsed = ([DateTimeOffset]::UtcNow - [DateTimeOffset]::Parse($last.LastAttemptUtc)).TotalMinutes - if ($elapsed -lt [int](Get-Setting 'RegionCooldownMinutes' 10)) { Write-GuardLog 'NOTICE VPN recovery cooldown active'; return } - } - $regions = @(Get-Setting 'PiaRegions' @()) - if ($regions.Count -lt 2) { throw 'Configure at least two PIA region IDs.' } - foreach ($region in $regions) { if ([string]$region -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') { throw 'Invalid PIA region ID.' } } - $current = Invoke-Pia -Arguments @('get','region') - $index = [Array]::IndexOf($regions, $current) - $next = [string]$regions[(($index + 1) % $regions.Count)] - $changed = Invoke-GuardAction "prepare PIA region recovery to $next" { - @{LastAttemptUtc=[DateTimeOffset]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 - # A successful CLI exit acknowledges a request, not a completed state change. - $before = Invoke-Pia -Arguments @('get','connectionstate') - if ($before -notin @('Disconnected','Disconnecting')) { - Invoke-Pia -Arguments @('disconnect') | Out-Null - } - $settled = $false - for ($poll = 0; $poll -lt 15; $poll++) { - if ((Invoke-Pia -Arguments @('get','connectionstate')) -eq 'Disconnected') { $settled = $true; break } - Start-Sleep -Seconds 2 - } - if (-not $settled) { throw 'PIA did not finish disconnecting; region and connect requests withheld.' } - Invoke-Pia -Arguments @('set','region',$next) | Out-Null - } - if (-not $changed) { return } - # Retry acknowledged connect requests only while fully Disconnected. - # Never issue connect during Disconnecting or interrupt Connecting. - $connectAttempts = 0; $nextConnectPoll = 0 - for ($poll = 0; $poll -lt 24; $poll++) { - $state = Invoke-Pia -Arguments @('get','connectionstate') - if ($state -eq 'Disconnected' -and $connectAttempts -lt 4 -and $poll -ge $nextConnectPoll) { - $requested = Invoke-GuardAction 'request PIA connect' { - Invoke-Pia -Arguments @('connect') | Out-Null - } - if (-not $requested) { return } - $connectAttempts++ - $nextConnectPoll = $poll + 3 - Write-GuardLog "PIA connect request $connectAttempts issued; awaiting Connected state" - } - if ($state -eq 'Connected' -and (Invoke-Pia -Arguments @('get','region')) -eq $next -and (Test-VpnReady) -and $null -ne (Get-ForwardedPort)) { - Write-GuardLog 'VPN recovery verified' - return - } - Start-Sleep -Seconds 5 - } - throw 'VPN recovery timed out after bounded polling; no verified tunnel and forwarded port. Manual reconnect may be required.' -} - -function Ensure-QbtRunning { - $processName = [string](Get-Setting 'QbtProcessName' 'qbittorrent') - if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } - if (-not (Get-Setting 'EnableQbtRestart' $false)) { Write-GuardLog 'NOTICE qBittorrent absent; restart disabled'; return $false } - if (-not (Test-VpnReady) -or -not (Test-StartupBinding)) { throw 'Restart blocked: VPN readiness or saved interface binding not verified.' } - $exe = [string](Get-Setting 'QbtExe' '') - if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw 'qBittorrent executable missing.' } - $started = Invoke-GuardAction 'restart qBittorrent' { - if (-not (Test-VpnReady)) { throw 'VPN changed before restart.' } - Start-Process -FilePath $exe -WindowStyle Minimized | Out-Null - } - if (-not $started) { return $false } - $deadline = [DateTime]::UtcNow.AddSeconds(60) - do { - if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } - Start-Sleep -Seconds 2 - } while ([DateTime]::UtcNow -lt $deadline) - throw 'qBittorrent process restart timed out.' -} -function Get-MkvIssue([string]$Path) { - $stream = $null - try { - $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) - $bytes = New-Object byte[] 12 - $count = $stream.Read($bytes, 0, 12) - if ($count -lt 4) { return 'Too short for EBML signature' } - if ($bytes[0] -eq 0x1a -and $bytes[1] -eq 0x45 -and $bytes[2] -eq 0xdf -and $bytes[3] -eq 0xa3) { return $null } - if ($bytes[0] -eq 0x4d -and $bytes[1] -eq 0x5a) { return 'Windows MZ executable header' } - return ('Not an EBML header: ' + [BitConverter]::ToString($bytes, 0, [Math]::Min($count, 12))) - } catch { return 'UNREADABLE: file could not be opened or read' } - finally { if ($null -ne $stream) { $stream.Dispose() } } -} -function Test-NoReparsePoint([string]$Root, [string]$Candidate) { - # Reject links/junctions at any component, including the save directory itself. - # Fail closed if a path component cannot be inspected. - try { - $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd([char]92, [char]47) - $candidateFull = [IO.Path]::GetFullPath($Candidate) - $prefix = $rootFull + [IO.Path]::DirectorySeparatorChar - if (-not $candidateFull.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $false } - $walk = $candidateFull - while ($walk) { - $item = Get-Item -LiteralPath $walk -Force -ErrorAction Stop - if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $false } - if ($walk.TrimEnd([char]92, [char]47) -ieq $rootFull) { return $true } - $parent = [IO.Path]::GetDirectoryName($walk.TrimEnd([char]92, [char]47)) - if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $walk) { return $false } - $walk = $parent - } - } catch { return $false } - return $false -} -function Resolve-TorrentFile($Torrent, [string]$Name) { - if ([IO.Path]::IsPathRooted($Name) -or $Name -match '(^|[\\/])\.\.([\\/]|$)' -or $Name -match '^[a-zA-Z]:') { return $null } - $root = [string]$Torrent.save_path - if ([string]::IsNullOrWhiteSpace($root)) { return $null } - $rootFull = [IO.Path]::GetFullPath($root).TrimEnd([char]92) + [char]92 - $candidate = [IO.Path]::GetFullPath((Join-Path $root ($Name -replace '/', '\'))) - if (-not $candidate.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { return $null } - if ((Test-Path -LiteralPath $candidate -PathType Leaf) -and (Test-NoReparsePoint $root $candidate)) { return $candidate } - $content = [string]$Torrent.content_path - if (-not [string]::IsNullOrWhiteSpace($content) -and [IO.Path]::GetFileName($content) -ieq [IO.Path]::GetFileName($Name) -and (Test-Path -LiteralPath $content -PathType Leaf)) { - $full = [IO.Path]::GetFullPath($content) - if ($full.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase) -and (Test-NoReparsePoint $root $full)) { return $full } - } - return $null -} - -$session = $null; $headers = $null; $uri = $null; $lock = $null -$loggedIn = $false -$stage = 'configuration' -try { - # Exclusive lock covers manual and scheduled runs from this installation. - try { $lock = [IO.File]::Open((Join-Path $InstallPath 'run.lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } - catch [IO.IOException] { Write-Output 'qBtGuard already running or lock unavailable; cycle skipped.'; return } - $c = Get-Content -LiteralPath (Join-Path $InstallPath 'config.json') -Raw | ConvertFrom-Json - $auditOnly = Get-Setting 'AuditOnly' $true - if ($auditOnly -isnot [bool]) { throw 'AuditOnly must be a JSON boolean.' } - foreach ($name in @('EnablePia','EnableVpnRecovery','EnablePortSync','EnableQbtRestart','DeleteProhibitedTorrents','DeleteInvalidMkvTorrents','BlockNoExtension','VerifyMkvSignatures','RemoteEndpoint')) { - if ((Get-Setting $name $false) -isnot [bool]) { throw "Invalid boolean setting: $name" } - } - $uri = [uri]([string]$c.QbtUrl) - if (-not $uri.IsAbsoluteUri -or $uri.Scheme -notin @('http','https') -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) { throw 'Invalid Web UI URL.' } - $baseUrl = $uri.AbsoluteUri.TrimEnd('/') - $piaEnabled = Get-Setting 'EnablePia' $false - $remote = Get-Setting 'RemoteEndpoint' $false - if ($remote -and ($piaEnabled -or (Get-Setting 'EnableQbtRestart' $false))) { throw 'Remote endpoint cannot use local PIA or watchdog controls.' } - if ((Get-Setting 'EnableQbtRestart' $false) -and -not $piaEnabled) { throw 'Restart requires PIA integration.' } - if (-not $piaEnabled -and ((Get-Setting 'EnableVpnRecovery' $false) -or (Get-Setting 'EnablePortSync' $false))) { throw 'VPN recovery and port sync require PIA integration.' } - if ($piaEnabled) { - $stage = 'VPN' - if (-not (Test-LocalEndpoint $uri)) { throw 'PIA endpoint must resolve exclusively to this computer.' } - Invoke-VpnRecovery - if (-not (Test-VpnReady)) { throw 'VPN and configured interface not ready; cycle blocked.' } - if (-not (Ensure-QbtRunning)) { return } - } - $stage = 'authentication' - $secure = (Get-Content -LiteralPath (Join-Path $InstallPath 'credential.dpapi') -Raw).Trim() | ConvertTo-SecureString - $credential = New-Object System.Management.Automation.PSCredential([string]$c.QbtUser, $secure) - $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession - $headers = @{ Referer=($baseUrl + '/'); Origin=$uri.GetLeftPart([UriPartial]::Authority) } - $deadline = [DateTime]::UtcNow.AddSeconds(60) - do { - try { - $login = Invoke-WebRequest -Uri ($baseUrl + '/api/v2/auth/login') -Method Post -WebSession $session -Headers $headers -Body @{username=[string]$c.QbtUser; password=$credential.GetNetworkCredential().Password} -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop - if ($login.Content.Trim() -ne 'Ok.') { throw 'Authentication rejected.' } - $loggedIn = $true - } catch { - if (-not $piaEnabled -or [DateTime]::UtcNow -ge $deadline) { throw } - Start-Sleep -Seconds 2 - } - } until ($loggedIn) - $stage = 'preferences' - if ($piaEnabled) { - $prefs = Invoke-QbtApi 'app/preferences' - if (-not (Test-LiveBinding $prefs)) { throw 'Live qBittorrent VPN interface binding mismatch.' } - if (Get-Setting 'EnablePortSync' $false) { - $port = Get-ForwardedPort - if ($null -ne $port -and [int]$prefs.listen_port -ne $port) { - $changed = Invoke-GuardAction "set qBittorrent listening port to $port" { - if (-not (Test-VpnReady) -or (Get-ForwardedPort) -ne $port) { throw 'VPN or port changed before synchronization.' } - Invoke-QbtApi 'app/setPreferences' 'Post' @{json=(@{listen_port=$port} | ConvertTo-Json -Compress)} | Out-Null - } - if ($changed -and [int](Invoke-QbtApi 'app/preferences').listen_port -ne $port) { throw 'Port change verification failed.' } - } elseif ($null -eq $port) { Write-GuardLog 'NOTICE no valid PIA forwarded port available' } - } - } - $stage = 'file scan' - $torrents = @(Invoke-QbtApi 'torrents/info' | ForEach-Object { $_ }) - $blocked = @($c.BlockedExtensions | ForEach-Object { ([string]$_).ToLowerInvariant() }) - $checked = 0; $findings = 0; $mkvChecked = 0; $mkvSkipped = 0; $mkvUnreadable = 0; $deleted = 0 - foreach ($torrent in $torrents) { - if ($null -eq $torrent -or [string]$torrent.hash -notmatch '^[a-fA-F0-9]{40,64}$') { throw 'Invalid torrent identity returned by API.' } - $hash = [string]$torrent.hash - $files = @(Invoke-QbtApi ('torrents/files?hash=' + $hash) | ForEach-Object { $_ }) - $prohibited = $false; $invalidMkv = $false - foreach ($file in $files) { - if ($null -eq $file) { continue } - $checked++ - $name = [string]$file.name - $ext = [IO.Path]::GetExtension($name).ToLowerInvariant() - if (((Get-Setting 'BlockNoExtension' $true) -and [string]::IsNullOrWhiteSpace($ext)) -or $ext -in $blocked) { - $prohibited = $true; $findings++ - Write-GuardLog ('ALERT prohibited file: torrent="{0}" file="{1}"' -f $torrent.name, $name) - } - if ((Get-Setting 'VerifyMkvSignatures' $true) -and $ext -eq '.mkv') { - if ([double]$torrent.progress -lt 1 -or [double]$file.progress -lt 1 -or $remote) { $mkvSkipped++; continue } - $path = Resolve-TorrentFile $torrent $name - if (-not $path) { $mkvSkipped++; Write-GuardLog ('NOTICE MKV unavailable: file="{0}"' -f $name); continue } - $mkvChecked++ - $issue = Get-MkvIssue $path - if ($issue) { - $findings++ - if ($issue -like 'UNREADABLE:*') { $mkvUnreadable++ } else { $invalidMkv = $true } - Write-GuardLog ('ALERT MKV signature: file="{0}" issue="{1}"' -f $name, $issue) - } - } - } - if (($prohibited -and (Get-Setting 'DeleteProhibitedTorrents' $false)) -or ($invalidMkv -and (Get-Setting 'DeleteInvalidMkvTorrents' $false))) { - $didDelete = Invoke-GuardAction "delete torrent $hash AND downloaded data" { - # Recheck existence before deletion; never call direct filesystem deletion. - $present = @(Invoke-QbtApi ('torrents/info?hashes=' + $hash) | ForEach-Object { $_ }) - if ($present.Count -ne 1 -or [string]$present[0].hash -ne $hash) { throw 'Torrent changed or disappeared before deletion.' } - Invoke-QbtApi 'torrents/delete' 'Post' @{hashes=$hash; deleteFiles='true'} | Out-Null - if (@(Invoke-QbtApi ('torrents/info?hashes=' + $hash) | ForEach-Object { $_ }).Count -ne 0) { throw 'Torrent removal not verified.' } - } - if ($didDelete) { $deleted++ } - } - } - $mode = 'ENFORCE'; if ($auditOnly) { $mode = 'AUDIT' } - Write-GuardLog "$mode completed: files=$checked findings=$findings mkvChecked=$mkvChecked mkvSkipped=$mkvSkipped mkvUnreadable=$mkvUnreadable torrentsRemoved=$deleted" -} catch { - Write-GuardLog ('ERROR: cycle failed at ' + $stage + ' (' + $_.Exception.GetType().Name + '). No successful completion recorded.') - exit 1 -} finally { - if ($loggedIn) { - try { Invoke-WebRequest -Uri ($baseUrl + '/api/v2/auth/logout') -Method Post -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | Out-Null } catch {} - } - if ($null -ne $lock) { $lock.Dispose() } -} From f9fc69e96df5a8fb2137c1a1a2e75e1bb138f87f Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:15:28 -0400 Subject: [PATCH 12/13] Preserve quiet calendar-day heartbeat and record live fixture QA --- QbtGuard.ps1 | 18 ++++++++++++++-- README.md | 8 ++++++- config.example.json | 1 + tests/QbtGuard.Logging.Tests.ps1 | 37 ++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 9669204..0e3423c 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -9,12 +9,25 @@ if ([string]::IsNullOrWhiteSpace($InstallPath)) { $InstallPath = Split-Path -Par Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $log = Join-Path $InstallPath 'qbtguard.log' +$script:cycleLogged = $false function Write-GuardLog([string]$Message) { $clean = $Message -replace '[\r\n\x00-\x1f]', ' ' Add-Content -LiteralPath $log -Value ('{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $clean) # Logging must not contaminate boolean function return values. + $script:cycleLogged = $true Write-Host $clean } +function Write-GuardCompletion([string]$Message, [bool]$Quiet = $false, [datetime]$Now = (Get-Date)) { + if (-not $Quiet) { Write-GuardLog $Message; return } + # Match the original local-calendar cutoff, not a rolling 24-hour timer. + $cutoff = $Now.Date.AddDays(-1).AddMinutes(1) + if (-not (Test-Path -LiteralPath $log) -or (Get-Item -LiteralPath $log).LastWriteTime -lt $cutoff) { + Write-GuardLog ("HEARTBEAT: " + $Message) + } else { + # Manual callers still see the successful summary; the disk log stays quiet. + Write-Host $Message + } +} function Get-Setting([string]$Name, $Default) { $property = $c.PSObject.Properties[$Name] if ($null -eq $property) { return $Default } @@ -234,7 +247,7 @@ try { $c = Get-Content -LiteralPath (Join-Path $InstallPath 'config.json') -Raw | ConvertFrom-Json $auditOnly = Get-Setting 'AuditOnly' $true if ($auditOnly -isnot [bool]) { throw 'AuditOnly must be a JSON boolean.' } - foreach ($name in @('EnablePia','EnableVpnRecovery','EnablePortSync','EnableQbtRestart','DeleteProhibitedTorrents','DeleteInvalidMkvTorrents','BlockNoExtension','VerifyMkvSignatures','RemoteEndpoint')) { + foreach ($name in @('EnablePia','EnableVpnRecovery','EnablePortSync','EnableQbtRestart','DeleteProhibitedTorrents','DeleteInvalidMkvTorrents','BlockNoExtension','VerifyMkvSignatures','RemoteEndpoint','QuietLogging')) { if ((Get-Setting $name $false) -isnot [bool]) { throw "Invalid boolean setting: $name" } } $uri = [uri]([string]$c.QbtUrl) @@ -333,7 +346,8 @@ try { } } $mode = 'ENFORCE'; if ($auditOnly) { $mode = 'AUDIT' } - Write-GuardLog "$mode completed: files=$checked findings=$findings mkvChecked=$mkvChecked mkvSkipped=$mkvSkipped mkvUnreadable=$mkvUnreadable torrentsRemoved=$deleted" + $quietCompletion = (Get-Setting 'QuietLogging' $false) -and -not $auditOnly -and -not $OnlyTorrentHash -and $findings -eq 0 -and -not $script:cycleLogged + Write-GuardCompletion "$mode completed: files=$checked findings=$findings mkvChecked=$mkvChecked mkvSkipped=$mkvSkipped mkvUnreadable=$mkvUnreadable torrentsRemoved=$deleted" $quietCompletion } catch { Write-GuardLog ('ERROR: cycle failed at ' + $stage + ' (' + $_.Exception.GetType().Name + '). No successful completion recorded.') exit 1 diff --git a/README.md b/README.md index 23b7d14..79bac84 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,14 @@ The VPN readiness gate uses PIA's Connected state and the configured Up adapter The recovery action waits up to 30 seconds for Disconnected before changing region. It then polls for a connected tunnel in the selected region and an assigned forwarded port. Connect requests are sent only from Disconnected, at least 15 seconds apart, with a maximum of four requests during 24 five-second polling intervals. A CLI success exit is treated as request acknowledgment, not proof of connection. Native-command overhead adds to the polling duration. Disconnect settling timeouts withhold the region/connect commands, and failures remain visible in the log. Audit mode performs no disconnects, region changes, connect requests or recovery waits. -The live recovery test on the initial system required a manual reconnect and therefore did not establish unattended recovery. Subsequent live testing must have an independent local reconnect fallback prepared before disconnecting the VPN; the kill switch can block the chat connection. Keep production protection restored between tests. +The first live recovery test required a manual reconnect; the revised implementation subsequently passed an unattended live recovery test. Future live testing must have an independent local reconnect fallback prepared before disconnecting the VPN; the kill switch can block the chat connection. Keep production protection restored between tests. ### One-torrent inspection and enforcement Use `QbtGuard.ps1 -OnlyTorrentHash ` to restrict file inspection and torrent deletion to one 40- or 64-character hexadecimal hash. The server query is filtered and the returned torrent list is filtered again locally. An absent target produces a scope message with zero matches and no torrent deletion. This option does not enable enforcement or override the configuration. VPN recovery, restart and port synchronization policies remain independent: disable those policies when doing a deletion-only fixture test. Omit the option for normal whole-queue operation. + +### Quiet production logging and live QA + +`QuietLogging` defaults to false. When enabled, an unscoped enforcement cycle with no findings or other log messages prints its completion summary to the console without appending it to disk. It writes a `HEARTBEAT` with the summary when the log is missing or was last modified before 00:01 on the previous local calendar day. Audit runs, scoped QA, findings, actions, notices and failures retain normal logging. The heartbeat runs only after a successful cycle; it is not proof that skipped MKVs were inspected. + +Live QA on the initial Windows/qBittorrent 4.3.6 system demonstrated guarded restart, forwarded-port synchronization, unattended recovery with the revised retry logic, and scoped deletion of prohibited-extension, extensionless and invalid-header MKV fixture torrents. Each successful deletion fixture also had both downloaded files verified absent. The first recovery attempt required manual assistance; the revised recovery test completed without it. A first extensionless fixture had unresolved location/completion issues; the repeated test used a verified 100% complete fixture. These observations do not establish clean-machine portability or full MKV integrity checking. Production cutover remains pending. diff --git a/config.example.json b/config.example.json index ea16dcf..312ccf5 100644 --- a/config.example.json +++ b/config.example.json @@ -27,6 +27,7 @@ ".zipx" ], "AuditOnly": true, + "QuietLogging": false, "IntervalMinutes": 10, "EnablePia": false, "EnableVpnRecovery": false, diff --git a/tests/QbtGuard.Logging.Tests.ps1 b/tests/QbtGuard.Logging.Tests.ps1 index c3c93a9..257f896 100644 --- a/tests/QbtGuard.Logging.Tests.ps1 +++ b/tests/QbtGuard.Logging.Tests.ps1 @@ -28,3 +28,40 @@ Describe 'Production logger preserves control return values' { @(Write-GuardLog 'fixture').Count | Should Be 0 } } + +Describe 'Quiet completion heartbeat' { + BeforeEach { + foreach ($name in @('Write-GuardLog','Write-GuardCompletion')) { + $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 + . ([scriptblock]::Create($definition.Extent.Text)) + } + $log=Join-Path $TestDrive ([guid]::NewGuid().ToString() + '.log') + $now=[datetime]'2026-10-16T12:00:00' + } + It 'writes a heartbeat for a missing log without returning pipeline output' { + @(Write-GuardCompletion 'ENFORCE completed' $true $now).Count | Should Be 0 + (Get-Content $log -Raw) | Should Match 'HEARTBEAT: ENFORCE completed' + } + It 'leaves a recent log untouched' { + [IO.File]::WriteAllText($log,'previous action') + (Get-Item $log).LastWriteTime=$now.AddHours(-1) + Write-GuardCompletion 'ENFORCE completed' $true $now + (Get-Content $log -Raw) | Should Be 'previous action' + } + It 'writes after the previous calendar day cutoff even under 24 hours of inactivity' { + [IO.File]::WriteAllText($log,'old') + (Get-Item $log).LastWriteTime=[datetime]'2026-10-15T00:00:30' + Write-GuardCompletion 'ENFORCE completed' $true ([datetime]'2026-10-16T00:00:00') + (Get-Content $log -Raw) | Should Match 'HEARTBEAT' + } + It 'does not write at the exact cutoff' { + [IO.File]::WriteAllText($log,'previous') + (Get-Item $log).LastWriteTime=$now.Date.AddDays(-1).AddMinutes(1) + Write-GuardCompletion 'ENFORCE completed' $true $now + (Get-Content $log -Raw) | Should Be 'previous' + } + It 'retains full summaries when quiet is disabled' { + Write-GuardCompletion 'AUDIT completed' $false $now + (Get-Content $log -Raw) | Should Match 'AUDIT completed' + } +} From cb49e075436dff6205158e4709052ce52c5d52af Mon Sep 17 00:00:00 2001 From: Chaz the TandemStacker <24739130+mc4bbs@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:14:58 -0400 Subject: [PATCH 13/13] Document completed pilot cutover, soak evidence and compatibility limits --- MIGRATION.md | 8 +++++++- QA.md | 40 ++++++++++++++++++++++++++++++++++++++++ README.md | 13 +++++++------ tests/README.md | 4 ++++ 4 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 QA.md diff --git a/MIGRATION.md b/MIGRATION.md index 6c120dd..d784a4f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,6 @@ # Consolidation and enforcement QA -This procedure is for anyone replacing older qBittorrent/PIA scripts. No production changes have been performed by this branch. +This procedure is for anyone replacing older qBittorrent/PIA scripts. The pilot installation completed a manual cutover; the installer itself never modifies or disables other installations. 1. Inventory all tasks and startup launchers targeting qBittorrent or PIA. Record their accounts, paths, intervals, current configuration and capabilities. Back up task XML, scripts, credentials and persistent state privately. 2. Install this candidate into a new directory with `-NoTask`. Retain the old working installation. Configure the candidate for the actual machine and VPN adapter; keep audit mode enabled. Do not reuse another Windows user's DPAPI credential. @@ -12,3 +12,9 @@ This procedure is for anyone replacing older qBittorrent/PIA scripts. No product 8. For rollback, disable and stop the new task/run before re-enabling the old task. Restore old configuration/state if altered. Deleted torrent data is irreversible through this script; recover from independent backups if available. Keep audit mode on and existing production protection in place until the candidate clears QA. The old deletion script may remain enforced during read-only comparison; never leave both enforcement engines scheduled after cutover. + +## Retiring the old installation + +After a successful observation period, export the current replacement task and preserve the old task XML, scripts, configuration, DPAPI files and state privately. Confirm the replacement task action points to the installation being retained. Remove only verified, disabled legacy task definitions; archive their script directories instead of deleting backups. Search task actions and startup launchers again for references to the archived paths. Never move the active installation without updating and verifying its task action. A directory with QA in its name can remain the production installation; renaming it is not required. + +Keep rollback data tied to the original Windows account and machine. Do not publish credentials, personal configuration, logs or task exports to GitHub. diff --git a/QA.md b/QA.md new file mode 100644 index 0000000..cba0f1f --- /dev/null +++ b/QA.md @@ -0,0 +1,40 @@ +# qBtGuard 0.2.0 pilot validation + +## Tested implementation + +Engine commit: `f9fc69e96df5a8fb2137c1a1a2e75e1bb138f87f`. +Environment: Windows, Windows PowerShell 5.1, Pester 3.4.0, qBittorrent 4.3.6 and local PIA/WireGuard integration. +No addresses, account names, credentials, personal torrent names or raw logs are included in this report. + +## Evidence + +| Check | Observed result | +|---|---| +| Windows CI syntax and regression suite | 84 passed; 0 failed/skipped/pending | +| Pilot regression suite | 84 passed; 0 failed/skipped/pending | +| Audit action gate | Mocked regressions plus scoped live audits proposed actions without deletion | +| VPN profile/interface compatibility | Saved display name and distinct internal ID accepted; live API binding checked | +| qBittorrent restart | Missing process restarted in an isolated enforcement test after readiness and binding checks | +| PIA port synchronization | Deliberately mismatched listening port changed to the forwarded port and verified | +| VPN recovery | Revised bounded connect/retry implementation completed an unattended region recovery | +| Prohibited-extension fixture | Scoped audit flagged the file; enforcement removed torrent; both fixture data files verified absent | +| Extensionless fixture | Repeated using a verified 100% complete torrent; scoped enforcement removed torrent and both data files | +| Invalid MKV header fixture | Scoped audit inspected one completed MKV and reported invalid EBML signature; enforcement removed torrent and both data files | +| Initial whole-queue enforcement | 1,499 files, 358 MKVs inspected, 13 skipped; no findings/unreadable MKVs/removals | +| Silent scheduled execution | Replacement task returned 0 and continued on its ten-minute schedule | +| October 19 check | Latest task run 09:07 local returned 0; next run 09:17; both legacy tasks disabled | +| Quiet log observation | Successful heartbeats October 17 and 19; no errors or findings in the supplied log; latest heartbeat inspected 1,906 files and 503 MKVs, with 7 skipped | + +The pilot cutover occurred October 15, 2026, after exporting old task definitions and copying installations into a private rollback directory. Production enabled PIA recovery, port synchronization, guarded restart and prohibited/extensionless deletion. Invalid-MKV deletion remained disabled after its isolated fixture test. The active installation retained its original QA directory name. + +## Interpretation and limits + +The observation covers October 15–19, not an independently verified history of every scheduled execution. Quiet logging omits uneventful enforcement summaries, and Task Scheduler's latest result establishes only the latest run. No organic prohibited-file event was recorded during the supplied observation; deletion evidence comes from controlled fixtures. + +The first recovery test needed a manual reconnect and did not pass unattended QA. The implementation was revised and the subsequent recovery test completed without that assistance. The initial extensionless deletion attempt left files behind with unresolved location/completion issues; it was repeated from torrent creation with correct source data and verified completion. Do not count the initial attempt as a successful data-deletion test. + +MKV inspection checks the EBML signature only. It does not establish complete container integrity, valid codecs, playability or absence of malware. Skipped and unreadable MKVs are not deletion triggers. A valid file in another container renamed as MKV can be flagged. + +The original heartbeat rule writes only if the log is missing or older than 00:01 on the previous local calendar day; every-other-day heartbeats can therefore be expected. Findings, notices, actions and failures log immediately. + +Clean-machine installation, different Windows/qBittorrent/PIA versions, remote API installations and alternate profile layouts are not established by this pilot. The task requires the installing user to be logged in and the computer awake. Following a reboot, user sign-in is required. No age-based disk cleanup is included. diff --git a/README.md b/README.md index 79bac84..a6e4b59 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # qBtGuard — 0.2.0 candidate -One Windows PowerShell 5.1 engine for torrent inspection, opt-in deletion, PIA recovery, forwarded-port synchronization, and qBittorrent restart. **This branch is a QA candidate, not an enforcement release.** +One Windows PowerShell 5.1 engine for torrent inspection, opt-in deletion, PIA recovery, forwarded-port synchronization, and qBittorrent restart. **Release candidate: validated on one Windows installation, with audit mode retained as the installation default.** ## Defaults and operation Installation starts in audit mode (`AuditOnly: true`). All corrective actions pass through a shared action gate: audit mode logs what would happen without switching VPN regions, changing ports, starting qBittorrent, or deleting torrents. Authentication/logout, logs and a temporary execution lock still operate. -Set `AuditOnly: false` only after QA and enable each desired action separately. Flags default to false, including on older configurations. `DeleteProhibitedTorrents` removes the **entire offending torrent AND its downloaded data** through qBittorrent. `DeleteInvalidMkvTorrents` is separate and defaults off. An unreadable MKV alone never causes deletion. +Set `AuditOnly: false` only after QA and enable each desired action separately. Corrective-action flags default to false, including on older configurations. `DeleteProhibitedTorrents` removes the **entire offending torrent AND its downloaded data** through qBittorrent. `DeleteInvalidMkvTorrents` is separate and defaults off. An unreadable MKV alone never causes deletion. | Setting | Meaning | |---|---| +| `QuietLogging` | Suppress uneventful unscoped enforcement summaries on disk; retain the calendar-cutoff heartbeat and diagnostic messages | | `QbtUrl`, `QbtUser` | User-supplied Web UI connection | | `RemoteEndpoint` | Explicit remote API mode; skips local MKV inspection and blocks local PIA/restart controls | | `EnablePia` | Require local PIA tunnel and qBittorrent interface binding | @@ -36,7 +37,7 @@ Keep the installer, engine, launcher and `config.example.json` together. Run as powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-QbtGuard.ps1 ``` -The installer asks for URL/user/password and saves the password with Windows DPAPI. The default destination is `%LOCALAPPDATA%\qBtGuard`. It refuses existing destinations and existing task names. Use `-NoTask` for isolated QA; `-TaskName` permits a distinct test task. The task repeats every ten minutes while the installing user is logged on and runs hidden. Do not schedule multiple installations against the same qBittorrent instance. +The installer asks for URL/user/password and saves the password with Windows DPAPI. The default destination is `%LOCALAPPDATA%\qBtGuard`. It refuses existing destinations and existing task names. Use `-NoTask` for isolated QA; `-TaskName` permits a distinct test task. The task repeats every ten minutes while the installing user is logged on and runs hidden. Following a reboot, sign in with that user before expecting runs. Locking the session is supported; sleep and sign-out prevent normal execution. The supplied scheduler settings use Windows defaults for battery restrictions. Do not schedule multiple installations against the same qBittorrent instance. After installation edit only the installed `config.json`. Paths, interface names, and PIA regions are configuration, not repository edits. For remote Web UI use set `RemoteEndpoint: true`; deletion still acts on that remote server if explicitly enabled. Remote downloads are not mapped into local paths by this candidate. @@ -52,7 +53,7 @@ Uninstall with `Uninstall-QbtGuard.ps1 -Destination ` and o ## QA status -The prior audit preview was exercised on one Windows installation. That evidence does not validate these new corrective actions. Windows CI parses all scripts and runs mocked Pester regressions. Clean-machine installation, real PIA failover, adapter/profile binding and qBittorrent restart are manual QA requirements. See [tests/README.md](tests/README.md). +Windows PowerShell 5.1 syntax checks and 84 Pester 3.4.0 tests passed in CI and on the pilot machine. Live fixture and control tests passed, and the single replacement task was observed operating from October 15 through October 19, 2026. The latest reported task result was successful; the available log contained successful heartbeats and no recorded errors. Quiet logging does not provide a complete history of every scheduled run. See [QA.md](QA.md) for evidence and remaining compatibility limits, and [tests/README.md](tests/README.md) for the test procedure. HTTP Web UI credentials are unencrypted in transit; use HTTPS or a trusted local connection. Do not expose the Web UI directly to the Internet. DPAPI files are tied to the installing Windows account and machine. @@ -76,6 +77,6 @@ Use `QbtGuard.ps1 -OnlyTorrentHash ` to restrict file inspection and torre ### Quiet production logging and live QA -`QuietLogging` defaults to false. When enabled, an unscoped enforcement cycle with no findings or other log messages prints its completion summary to the console without appending it to disk. It writes a `HEARTBEAT` with the summary when the log is missing or was last modified before 00:01 on the previous local calendar day. Audit runs, scoped QA, findings, actions, notices and failures retain normal logging. The heartbeat runs only after a successful cycle; it is not proof that skipped MKVs were inspected. +`QuietLogging` defaults to false. When enabled, an unscoped enforcement cycle with no findings or other log messages prints its completion summary to the console without appending it to disk. It writes a `HEARTBEAT` with the summary when the log is missing or was last modified before 00:01 on the previous local calendar day. This inherited rule can yield a heartbeat every other day; it is not a once-per-day timer. Audit runs, scoped QA, findings, actions, notices and failures retain normal logging. The heartbeat runs only after a successful cycle; it is not proof that skipped MKVs were inspected. -Live QA on the initial Windows/qBittorrent 4.3.6 system demonstrated guarded restart, forwarded-port synchronization, unattended recovery with the revised retry logic, and scoped deletion of prohibited-extension, extensionless and invalid-header MKV fixture torrents. Each successful deletion fixture also had both downloaded files verified absent. The first recovery attempt required manual assistance; the revised recovery test completed without it. A first extensionless fixture had unresolved location/completion issues; the repeated test used a verified 100% complete fixture. These observations do not establish clean-machine portability or full MKV integrity checking. Production cutover remains pending. +Live QA on the initial Windows/qBittorrent 4.3.6 system demonstrated guarded restart, forwarded-port synchronization, unattended recovery with the revised retry logic, and scoped deletion of prohibited-extension, extensionless and invalid-header MKV fixture torrents. Each successful deletion fixture also had both downloaded files verified absent. The first recovery attempt required manual assistance; the revised recovery test completed without it. A first extensionless fixture had unresolved location/completion issues; the repeated test used a verified 100% complete fixture. These observations do not establish clean-machine portability or full MKV integrity checking. The pilot was subsequently cut over to one enforcement task; the previous tasks were disabled and rollback files retained. See [QA.md](QA.md). diff --git a/tests/README.md b/tests/README.md index bedb941..d522b9c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -9,3 +9,7 @@ Import-Module Pester -RequiredVersion 3.4.0; Invoke-Pester -Script .\tests Tests use disposable local fixtures, DPAPI test credentials and mocked API/process/VPN/task commands. They never contact real qBittorrent or PIA. They cover path traversal and reparse points, header findings/privacy, API failure handling, shared audit action gating, deletion policy and hash targeting, disabled/unhealthy restart, remote-local separation, forwarding-port validation and recovery cooldown. CI checks syntax and runs these tests on Windows. Real VPN/interface/profile behavior, clean installation, scheduler registration and destructive policies still require isolated Windows integration QA using disposable downloads. A mocked test pass is not permission to deploy enforcement. + +## Pilot evidence + +The tested engine at `f9fc69e96df5a8fb2137c1a1a2e75e1bb138f87f` passed 84 tests with zero failures or skipped tests in Windows CI and on the initial Windows system. Live action tests and the October 15–19 observation are documented in [QA.md](../QA.md). This is one-machine integration evidence, not proof of compatibility across other versions or installations.