From 2819d31490d02ce0e0ce11d3ee28d02469985b72 Mon Sep 17 00:00:00 2001 From: seo-amugae Date: Thu, 8 Oct 2026 01:14:17 +0900 Subject: [PATCH] Harden hidden startup and sleep recovery --- README.md | 33 +++++++-- config.ini | 4 ++ screen-rtsp.ps1 | 181 +++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 205 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 297bb37..9789b0c 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,13 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서 - 기존 MediaMTX가 RTSP 포트를 사용 중이면 해당 프로세스 재사용 - 자동 생성 MediaMTX 설정은 RTSP over TCP만 사용해 UDP RTP 포트 충돌 방지 - 로그인 시 자동 실행 작업 등록 및 제거 +- 기본 실행과 예약 작업 모두 PowerShell 창을 표시하지 않는 숨김 백그라운드 실행 +- 절전 해제나 장시간 polling 중단 감지 시 전체 채널 강제 재구성 +- 로그인 트리거와 1분 간격 무기한 복구 트리거를 이용한 Watchdog 지속 복구 - `config.ini` 기반 기능별 설정과 명령줄 override - 오디오 캡처/AAC 트랙을 `1` 또는 `0`으로 활성화·비활성화 - FFmpeg와 MediaMTX 부재 시 HTTPS 다운로드, SHA-256 검증, 자동 설치 -- 모니터·오디오·NIC·프로세스·설정·안전장치용 내장 시뮬레이션 30건 +- 모니터·오디오·NIC·프로세스·절전 복구·설정·안전장치용 내장 시뮬레이션 35건 ## RTSP URL @@ -132,7 +135,7 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다. ## 내장 시뮬레이션 -실제 송출 프로세스나 예약 작업을 만들지 않고 주요 판단 로직 30건을 검사합니다. +실제 송출 프로세스나 예약 작업을 만들지 않고 주요 판단 로직 35건을 검사합니다. ```powershell .\screen-rtsp.ps1 -RunSimulationTests @@ -153,20 +156,38 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다. ## 설치 및 실행 -관리자 PowerShell에서 실행 정책을 우회해 시작합니다. +관리자 PowerShell에서 실행 정책을 우회해 시작합니다. 기본 실행은 최종 설정값과 명령줄 override를 숨김 worker에 전달한 뒤 현재 PowerShell 프롬프트로 즉시 돌아옵니다. 현재 창을 닫아도 송출은 계속됩니다. ```powershell Set-Location C:\RTSP powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 ``` +진단 목적으로 현재 콘솔에서 직접 로그를 확인할 때만 `-Foreground`를 사용합니다. 이 경우 창을 닫거나 `Ctrl+C`를 누르면 송출도 종료됩니다. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -Foreground +``` + 로그인 자동 실행은 대화형 사용자 세션에 예약 작업을 등록합니다. Desktop Duplication과 사용자 재생 오디오를 캡처해야 하므로 Windows 서비스가 아니라 로그인 트리거를 사용합니다. ```powershell powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -InstallStartup +Start-ScheduledTask -TaskName "Screen RTSP Watchdog" ``` -등록된 작업은 `config.ini` 경로만 전달하므로 설정 파일을 수정한 뒤 작업을 다시 시작하면 변경값이 적용됩니다. +등록된 작업은 다음 복구 정책을 사용합니다. + +- PowerShell 작업 인자와 프로세스 창 스타일을 모두 `Hidden`으로 지정 +- 로그인 즉시 시작 +- 1분마다 작업 생존을 무기한 확인하고, 실행 중이면 `IgnoreNew`로 중복 실행 방지 +- 작업 실패 시 1분 간격으로 최대 999회 재시작하고, 횟수가 소진돼도 무기한 반복 트리거가 계속 복구 +- 절전 중 놓친 실행은 `StartWhenAvailable`로 절전 해제 후 시작 +- 배터리 전환 시에도 중지하지 않으며 실행 시간 제한 없음 + +Watchdog 내부에서는 Windows `PowerModeChanged/Resume` 이벤트를 감시하고, 이벤트 누락에 대비해 polling 시간 공백도 함께 확인합니다. 절전 해제가 감지되면 FFmpeg 2채널을 모두 종료하고 모니터·NIC·오디오·GPU 구성을 다시 읽은 뒤 안정화 시간 이후 재시작합니다. 모니터 분리 시에는 채널을 종료하고 계속 대기하며, 필요한 수만큼 다시 연결되면 동일하게 자동 복구합니다. + +등록된 작업은 `config.ini` 경로만 전달하므로 설정 파일을 수정한 뒤 작업을 다시 시작하면 변경값이 적용됩니다. 기존 작업이 등록돼 있다면 업데이트된 복구 설정을 적용하기 위해 `-InstallStartup`을 다시 실행해야 합니다. ```powershell powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -StartupStatus @@ -174,6 +195,10 @@ Stop-ScheduledTask -TaskName "Screen RTSP Watchdog" Start-ScheduledTask -TaskName "Screen RTSP Watchdog" ``` +`Stop-ScheduledTask`만 실행하면 무기한 반복 트리거가 1분 이내에 다시 시작할 수 있습니다. 완전히 중지하려면 아래의 `-UninstallStartup`을 사용합니다. + +무기한 복구는 사용자가 로그인한 대화형 세션에서 예약 작업이 등록·활성화된 동안 적용됩니다. 작업 자체를 삭제하거나 비활성화한 경우와 전원이 꺼진 동안에는 실행할 수 없으며, 다음 로그인 때 다시 시작합니다. + 자동 실행 제거: ```powershell diff --git a/config.ini b/config.ini index 51a1c0c..560ea08 100644 --- a/config.ini +++ b/config.ini @@ -13,7 +13,10 @@ // 슬래시 두 개로 시작하는 이 줄도 설명으로 처리됩니다. # # 실행 +# 기본 실행은 즉시 숨김 백그라운드 worker를 시작하고 현재 창으로 돌아옵니다. # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 +# 진단 중 콘솔 로그를 직접 보려는 경우에만 -Foreground를 사용합니다. +# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -Foreground # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -ShowConfiguration # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -InstallDependenciesOnly # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -RunSimulationTests @@ -25,6 +28,7 @@ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -InstallStartup # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -StartupStatus # Start-ScheduledTask -TaskName "Screen RTSP Watchdog" +# Stop-ScheduledTask는 1분 이내 자동 복구되므로 완전 중지는 -UninstallStartup을 사용합니다. # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -UninstallStartup # # GPU와 FFmpeg 지원 확인 diff --git a/screen-rtsp.ps1 b/screen-rtsp.ps1 index 569d072..8eca0c8 100644 --- a/screen-rtsp.ps1 +++ b/screen-rtsp.ps1 @@ -54,6 +54,10 @@ param( [switch]$ShowConfiguration, [switch]$InstallDependenciesOnly, [switch]$RunSimulationTests, + [switch]$Foreground, + + [Parameter(DontShow = $true)] + [switch]$WatchdogWorker, [Parameter(DontShow = $true)] [switch]$AudioWorker, @@ -833,24 +837,74 @@ function Join-ProcessArguments { function Get-StartupArgumentList { $values = @( - "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, - "-ConfigPath", $ConfigPath + "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", + "-File", $PSCommandPath, "-ConfigPath", $ConfigPath, "-WatchdogWorker" ) return Join-ProcessArguments $values } +function Get-HiddenWorkerArgumentList { + $values = @( + "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", + "-File", $PSCommandPath, "-ConfigPath", $ConfigPath, "-WatchdogWorker", + "-EncoderPriorityCsv", ($EncoderPriority -join ","), + "-ChannelCount", [string]$ChannelCount, + "-FrameRate", [string]$FrameRate, + "-VideoBitrate", $VideoBitrate, + "-AudioBitrate", $AudioBitrate, + "-AudioEnabled", [string]$AudioEnabled, + "-AudioSignalThreshold", $AudioSignalThreshold.ToString( + [System.Globalization.CultureInfo]::InvariantCulture + ), + "-NicDescription", $NicDescription, + "-HealthTarget", $HealthTarget, + "-StableTimeSec", [string]$StableTimeSec, + "-PollIntervalSec", [string]$PollIntervalSec, + "-RtspPort", [string]$RtspPort, + "-AutoInstallDependencies", [string]$AutoInstallDependencies, + "-FFmpegDownloadUrl", $FFmpegDownloadUrl, + "-FFmpegSha256Url", $FFmpegSha256Url, + "-MediaMTXDownloadUrl", $MediaMTXDownloadUrl, + "-MediaMTXSha256", $MediaMTXSha256 + ) + return Join-ProcessArguments $values +} + +function Get-StartupTriggers { + $logonTrigger = New-ScheduledTaskTrigger -AtLogOn ` + -User "$env:USERDOMAIN\$env:USERNAME" + $recoveryTrigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) ` + -RepetitionInterval (New-TimeSpan -Minutes 1) + return @($logonTrigger, $recoveryTrigger) +} + +function Get-StartupSettings { + return New-ScheduledTaskSettingsSet -Hidden -StartWhenAvailable ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew +} + +function Start-HiddenWatchdog { + $process = Start-Process -FilePath $PowerShellPath ` + -ArgumentList (Get-HiddenWorkerArgumentList) ` + -WorkingDirectory $BaseDir -WindowStyle Hidden -PassThru + Write-Host "숨김 백그라운드 Watchdog를 시작했습니다. PID=$($process.Id)" + Write-Host "로그: $WatchdogLog" +} + function Install-StartupTask { $action = New-ScheduledTaskAction -Execute $PowerShellPath -Argument (Get-StartupArgumentList) ` -WorkingDirectory $BaseDir - $trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME" + $triggers = @(Get-StartupTriggers) $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" ` -LogonType Interactive -RunLevel Highest - $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` - -DontStopIfGoingOnBatteries -RestartCount 10 -RestartInterval (New-TimeSpan -Minutes 1) ` - -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew - Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` + $settings = Get-StartupSettings + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers ` -Principal $principal -Settings $settings -Force | Out-Null Write-Host "자동 실행 작업을 등록했습니다: $TaskName" + Write-Host "숨김 실행: 사용" + Write-Host "복구: 로그인 시 시작 + 1분 간격 무기한 확인 + 실패 시 재시작" Write-Host "설정 파일: $ConfigPath" } @@ -905,6 +959,15 @@ if ($StartupStatus) { NextRunTime = $taskInfo.NextRunTime Execute = $task.Actions.Execute Arguments = $task.Actions.Arguments + Hidden = $task.Settings.Hidden + StartWhenAvailable = $task.Settings.StartWhenAvailable + RestartCount = $task.Settings.RestartCount + RestartInterval = $task.Settings.RestartInterval + MultipleInstances = $task.Settings.MultipleInstances + TriggerCount = @($task.Triggers).Count + RecoveryInterval = (@($task.Triggers | Where-Object { + $_.Repetition -and $_.Repetition.Interval + } | ForEach-Object { $_.Repetition.Interval }) -join ", ") } | Format-List exit 0 } @@ -1759,6 +1822,29 @@ function Get-StreamingReadinessReason { return $null } +function Test-WatchdogResumeGap { + param( + [DateTime]$PreviousPollAt, + [DateTime]$CurrentPollAt + ) + $resumeThresholdSec = [Math]::Max(10.0, [double]$PollIntervalSec * 3.0) + return (($CurrentPollAt - $PreviousPollAt).TotalSeconds -ge $resumeThresholdSec) +} + +function Receive-WatchdogPowerResume { + param([string]$SourceIdentifier) + + $resumeDetected = $false + foreach ($powerEvent in @(Get-Event -SourceIdentifier $SourceIdentifier ` + -ErrorAction SilentlyContinue)) { + if ([string]$powerEvent.SourceEventArgs.Mode -eq "Resume") { + $resumeDetected = $true + } + Remove-Event -EventIdentifier $powerEvent.EventIdentifier -ErrorAction SilentlyContinue + } + return $resumeDetected +} + function Invoke-SimulationTests { $results = New-Object 'System.Collections.Generic.List[object]' @@ -1987,9 +2073,50 @@ function Invoke-SimulationTests { (Get-EncoderDefinition "Intel").Name -eq "h264_qsv") } - Invoke-SimulationCase "자동 실행은 config.ini 경로만 전달" { + Invoke-SimulationCase "자동 실행은 숨김 worker와 config.ini만 전달" { $arguments = Get-StartupArgumentList - return ($arguments -match '-ConfigPath' -and $arguments -notmatch '-VideoBitrate') + return ($arguments -match '-WindowStyle\s+Hidden' -and + $arguments -match '-WatchdogWorker' -and + $arguments -match '-ConfigPath' -and + $arguments -notmatch '-VideoBitrate') + } + + Invoke-SimulationCase "직접 숨김 실행은 최종 설정과 override를 전달" { + $arguments = Get-HiddenWorkerArgumentList + return ($arguments -match '-EncoderPriorityCsv' -and + $arguments -match '-ChannelCount\s+2' -and + $arguments -match '-AudioEnabled\s+[01]' -and + $arguments -match '-RtspPort\s+8554' -and + $arguments -match '-WatchdogWorker') + } + + Invoke-SimulationCase "예약 작업은 1분 간격으로 무기한 복구 확인" { + $triggers = @(Get-StartupTriggers) + $recoveryTrigger = $triggers | Where-Object { + $_.Repetition -and $_.Repetition.Interval + } | Select-Object -First 1 + return ($triggers.Count -eq 2 -and + $recoveryTrigger.Repetition.Interval -eq "PT1M" -and + [string]::IsNullOrWhiteSpace($recoveryTrigger.Repetition.Duration)) + } + + Invoke-SimulationCase "예약 작업 숨김/누락 실행/재시작 설정" { + $settings = Get-StartupSettings + return ($settings.Hidden -and $settings.StartWhenAvailable -and + $settings.RestartCount -eq 999 -and + $settings.RestartInterval -eq "PT1M" -and + $settings.ExecutionTimeLimit -eq "PT0S" -and + $settings.MultipleInstances -eq "IgnoreNew") + } + + Invoke-SimulationCase "정상 polling 간격은 절전 복구로 오인하지 않음" { + $previous = [DateTime]::UtcNow + return (-not (Test-WatchdogResumeGap $previous $previous.AddSeconds(2))) + } + + Invoke-SimulationCase "긴 polling 공백을 절전 해제로 감지" { + $previous = [DateTime]::UtcNow + return (Test-WatchdogResumeGap $previous $previous.AddMinutes(5)) } Invoke-SimulationCase "2채널 FFmpeg/오디오 worker가 모두 생존" { @@ -2066,13 +2193,24 @@ if ($RunSimulationTests) { exit 0 } +if (-not $WatchdogWorker -and -not $Foreground) { + Start-HiddenWatchdog + exit 0 +} + $mutex = New-Object System.Threading.Mutex($false, "Local\ScreenRtspWatchdog") $hasMutex = $false +$powerEventSourceIdentifier = "ScreenRtspPowerMode-$PID" +$powerEventRegistered = $false try { $hasMutex = $mutex.WaitOne(0, $false) if (-not $hasMutex) { throw "Screen RTSP Watchdog가 이미 실행 중입니다." } + Register-ObjectEvent -InputObject ([Microsoft.Win32.SystemEvents]) ` + -EventName PowerModeChanged -SourceIdentifier $powerEventSourceIdentifier | Out-Null + $powerEventRegistered = $true + Assert-RequiredFiles Ensure-MediaMTXConfig $encoderList = Test-FFmpegCapabilities @@ -2092,9 +2230,28 @@ try { $healthySince = $null $lastReason = "" $lastGpuCheck = [DateTime]::MinValue + $lastPollAt = Get-Date while ($true) { try { + $currentPollAt = Get-Date + $powerResumeDetected = Receive-WatchdogPowerResume ` + -SourceIdentifier $powerEventSourceIdentifier + $resumeGapDetected = Test-WatchdogResumeGap ` + -PreviousPollAt $lastPollAt -CurrentPollAt $currentPollAt + if ($powerResumeDetected -or $resumeGapDetected) { + $pollGapSec = [Math]::Round(($currentPollAt - $lastPollAt).TotalSeconds, 1) + $resumeSource = if ($powerResumeDetected) { "Windows 절전 해제 이벤트" } ` + else { "polling 공백 ${pollGapSec}초" } + Write-Log "$resumeSource 감지 - 전체 채널 재구성" + Stop-Streams + $lastSystemSignature = "" + $healthySince = $null + $lastReason = "" + $lastGpuCheck = [DateTime]::MinValue + } + $lastPollAt = $currentPollAt + $mediaReady = Start-MediaMTX if (-not $mediaReady) { Stop-Streams @@ -2189,6 +2346,12 @@ catch { } finally { Stop-Streams + if ($powerEventRegistered) { + Unregister-Event -SourceIdentifier $powerEventSourceIdentifier ` + -ErrorAction SilentlyContinue + Get-Event -SourceIdentifier $powerEventSourceIdentifier -ErrorAction SilentlyContinue | + Remove-Event -ErrorAction SilentlyContinue + } if ($OwnsMediaMTX -and (Test-ProcessRunning $MediaMTXProcess)) { Stop-Process -Id $MediaMTXProcess.Id -Force -ErrorAction SilentlyContinue }