<# ВНИМАНИЕ: файл сохранён в UTF-8 С СИГНАТУРОЙ (BOM). Не убирайте её. Windows PowerShell 5.1 — тот, что стоит в системе по умолчанию, — читает файлы без сигнатуры как ANSI. Русские строки тогда превращаются в мусор, и скрипт не запускается вовсе: разбор ломается на первой же кавычке внутри испорченного текста. Проверено на живой машине. .SYNOPSIS Настройка туннеля от базы 1С к ФинЭксперту. .DESCRIPTION Скрипт для машины, где лежит файловая база 1С. Он не открывает наружу ни одного порта: машина сама подключается к нашему серверу и пробрасывает ему порт своего веб-сервера. Поэтому белый адрес не нужен, и настраивать маршрутизатор не придётся. Запускается в два приёма: .\tunnel-setup.ps1 -Step key # создать ключ, показать публичную часть .\tunnel-setup.ps1 -Step service # поднять туннель службой (после ответа) Между шагами публичный ключ отправляется нам: без него сервер соединение не примет. .NOTES Требуется PowerShell от имени администратора. #> [CmdletBinding()] param( [ValidateSet('check', 'publish', 'verify', 'key', 'service', 'status', 'remove')] [string]$Step = 'check', # Публикация: часть названия базы из списка 1С и латинское имя, под # которым база появится на веб-сервере. [string]$DbName = '', [string]$Name = '', # Порт веб-сервера на этой машине, где опубликована база. [int]$LocalPort = 80, # Порт на нашем сервере. Свой у каждого клиента — его сообщаем мы. [int]$RemotePort = 0, [string]$Server = '159.194.252.19', [string]$User = 'finhub_tunnel' ) $ErrorActionPreference = 'Stop' $KeyPath = "$env:ProgramData\FinExpert\tunnel_key" $TaskName = 'FinExpert Tunnel' function Write-Head($text) { Write-Host '' Write-Host "=== $text ===" -ForegroundColor Cyan } function Write-Ok($text) { Write-Host " [ок] $text" -ForegroundColor Green } function Write-Bad($text) { Write-Host " [!] $text" -ForegroundColor Red } function Write-Info($text) { Write-Host " $text" -ForegroundColor Gray } function Test-Admin { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } # --- Проверка окружения --------------------------------------------------- # Смысл шага: узнать всё, что мешает подключению, до того как что-то менять. # Разрядность 1С и веб-сервера, наличие ssh, доступность публикации. function Invoke-Check { Write-Head 'Проверка машины' Write-Info "Windows: $((Get-CimInstance Win32_OperatingSystem).Caption)" Write-Info "Разрядность системы: $(if ([Environment]::Is64BitOperatingSystem) { '64 бита' } else { '32 бита' })" if (Test-Admin) { Write-Ok 'Скрипт запущен от администратора' } else { Write-Bad 'Запущено НЕ от администратора.' Write-Info 'Часть проверок будет пропущена, а службу создать не выйдет.' Write-Info 'Закройте окно, найдите PowerShell в меню «Пуск», нажмите на нём' Write-Info 'правой кнопкой → «Запуск от имени администратора», и повторите.' } # Клиент OpenSSH встроен в Windows 10/11 и Server 2019+. Если его нет, # ставится компонентом, без загрузки чего-либо со стороны. $ssh = Get-Command ssh -ErrorAction SilentlyContinue if ($ssh) { Write-Ok "Клиент SSH есть: $($ssh.Source)" } else { Write-Bad 'Клиента SSH нет. Установите компонент командой:' Write-Info 'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0' } # Платформа 1С: важна разрядность — модуль веб-сервера у неё отдельный # для 32 и 64 бит, и несовпадение ломает публикацию без внятной ошибки. $onec = @( "$env:ProgramFiles\1cv8", "${env:ProgramFiles(x86)}\1cv8" ) | Where-Object { Test-Path $_ } if ($onec) { foreach ($path in $onec) { $bits = if ($path -like '*x86*') { '32 бита' } else { '64 бита' } $versions = Get-ChildItem $path -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | Select-Object -ExpandProperty Name Write-Ok "1С ($bits): $path" if ($versions) { Write-Info "версии: $($versions -join ', ')" } } } else { Write-Bad 'Каталог 1С не найден в обычных местах — укажем путь вручную' } # Веб-сервер: без него база наружу ничего не отдаёт. $apache = Get-Service -Name 'Apache*' -ErrorAction SilentlyContinue $iis = Get-Service -Name 'W3SVC' -ErrorAction SilentlyContinue if ($apache) { Write-Ok "Apache: служба $($apache.Name), состояние $($apache.Status)" } elseif ($iis) { Write-Ok "IIS: состояние $($iis.Status)" } else { Write-Bad 'Веб-сервера нет. Его нужно поставить — без него OData не публикуется' } # Публикация 1С в IIS работает через ISAPI-расширение. Компоненты для # него в Windows Server по умолчанию не установлены, и без них # публикация проходит, а приложение отдаёт ошибку 500. if ($iis) { Write-Head 'Компоненты IIS' try { $needed = 'Web-ISAPI-Ext', 'Web-ISAPI-Filter', 'Web-Windows-Auth' foreach ($name in $needed) { $feature = Get-WindowsFeature -Name $name -ErrorAction Stop if ($feature.Installed) { Write-Ok "$name установлен" } else { Write-Bad "$name НЕ установлен — понадобится" } } } catch { Write-Info 'Состав компонентов виден только администратору' } Write-Head 'Сайты IIS' try { Import-Module WebAdministration -ErrorAction Stop Get-Website | ForEach-Object { $binding = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ', ' Write-Info "$($_.Name): $($_.State), $binding" } } catch { Write-Info 'Список сайтов виден только администратору' } } # Модуль расширения веб-сервера — отдельный компонент установки 1С. # Без него публиковать нечем, и это самая частая причина затыка. Write-Head 'Модуль расширения веб-сервера 1С' $modules = @() foreach ($root in $onec) { $modules += Get-ChildItem -Path $root -Recurse -Filter 'wsisapi.dll' ` -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName } if ($modules) { $modules | Select-Object -First 6 | ForEach-Object { Write-Ok $_ } } else { Write-Bad 'Модуля нет: в установке платформы 1С не отмечен компонент' Write-Info '«Модули расширения веб-сервера». Доустанавливается изменением установки.' } # Где лежат базы: список хранится у каждого пользователя свой. Write-Head 'Информационные базы этого пользователя' $listFile = "$env:APPDATA\1C\1CEStart\ibases.v8i" if (Test-Path $listFile) { $current = $null foreach ($line in Get-Content $listFile -Encoding UTF8) { if ($line -match '^\[(.+)\]$') { $current = $Matches[1] } elseif ($line -match '^Connect=(.+)$') { $connect = $Matches[1] $kind = if ($connect -match 'File=') { 'файловая' } else { 'серверная' } Write-Info "$current — $kind" Write-Info " $connect" } } } else { Write-Info "Список баз не найден: $listFile" } Write-Head 'Проверка публикации' $url = "http://localhost:$LocalPort/" try { $response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 5 ` -ErrorAction Stop Write-Ok "Веб-сервер отвечает на $url (код $($response.StatusCode))" } catch [System.Net.WebException] { # 401 — это успех: значит публикация есть и просит пароль. $code = [int]$_.Exception.Response.StatusCode if ($code -eq 401) { Write-Ok "Веб-сервер отвечает на $url и просит пароль" } else { Write-Bad "Веб-сервер на $url не ответил: $($_.Exception.Message)" } } catch { Write-Bad "Веб-сервер на $url не ответил. Опубликована ли база?" } Write-Head 'Что дальше' Write-Info 'Пришлите этот отчёт нам — по нему скажем, что делать следующим шагом.' } # --- Публикация базы на IIS ----------------------------------------------- # Делается скриптом, а не руками в конфигураторе, по двум причинам: баз # обычно несколько, и одна забытая галочка «Публиковать стандартный интерфейс # OData» стоит потом часа поисков. function Get-DatabasePath($namePart) { # Путь к файловой базе по части её названия в списке 1С. # # Путь берётся из списка, а не набирается руками: в названиях каталогов # попадаются и пробелы, и подчёркивания, и перепутать их при наборе # проще, чем кажется. # $listFile = "$env:APPDATA\1C\1CEStart\ibases.v8i" if (-not (Test-Path $listFile)) { throw "Список баз не найден: $listFile" } $current = $null $found = @() foreach ($line in Get-Content $listFile -Encoding UTF8) { if ($line -match '^\[(.+)\]$') { $current = $Matches[1] } elseif ($line -match '^Connect=File="(.+)"' -and $current -like "*$namePart*") { $found += [pscustomobject]@{ Name = $current; Path = $Matches[1] } } } if ($found.Count -eq 0) { throw "Файловая база с «$namePart» в названии не найдена" } if ($found.Count -gt 1) { Write-Bad 'Под описание подходит несколько баз — уточните название:' $found | ForEach-Object { Write-Info " $($_.Name)" } throw 'Уточните -DbName' } return $found[0] } function Get-WebInst { # Свежая 64-разрядная платформа с модулем расширения веб-сервера. $candidates = Get-ChildItem "$env:ProgramFiles\1cv8" -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | Sort-Object { [version]$_.Name } -Descending foreach ($dir in $candidates) { $webinst = Join-Path $dir.FullName 'bin\webinst.exe' $module = Join-Path $dir.FullName 'bin\wsisapi.dll' if ((Test-Path $webinst) -and (Test-Path $module)) { return [pscustomobject]@{ Exe = $webinst; Module = $module; Version = $dir.Name } } } throw 'Не нашёл платформу с модулем расширения веб-сервера (wsisapi.dll)' } function Invoke-Publish { if (-not (Test-Admin)) { throw 'Нужен запуск от имени администратора' } if (-not $DbName) { throw 'Укажите базу: -DbName "часть названия"' } if (-not $Name) { throw 'Укажите латинское имя публикации: -Name kryukova' } if ($Name -notmatch '^[a-z0-9_-]+$') { throw 'Имя публикации — только латинские буквы, цифры, дефис и подчёркивание' } Write-Head 'Публикация базы на веб-сервере' $db = Get-DatabasePath $DbName Write-Ok "База: $($db.Name)" Write-Info "Путь: $($db.Path)" if (-not (Test-Path $db.Path)) { throw "Каталог базы не существует: $($db.Path)" } $platform = Get-WebInst Write-Ok "Платформа: $($platform.Version)" $target = "C:\inetpub\wwwroot\$Name" if (-not (Test-Path $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null } # Строка соединения передаётся webinst в кавычках, а внутренние кавычки # вокруг пути при этом удваиваются — иначе путь с пробелами обрывается # на первом же пробеле, и база «не находится». $connect = 'File=""' + $db.Path + '"";' $arguments = "-publish -iis -wsdir $Name -dir `"$target`" " + "-connstr `"$connect`" -confPath `"$target\default.vrd`"" Write-Info 'Публикую…' $result = Start-Process -FilePath $platform.Exe -ArgumentList $arguments ` -Wait -PassThru -NoNewWindow if ($result.ExitCode -ne 0) { throw "webinst вернул код $($result.ExitCode). Запущен ли IIS и есть ли компоненты ISAPI?" } Write-Ok "Публикация создана: $target" Enable-StandardOdata "$target\default.vrd" Grant-DatabaseAccess $db.Path $Name Set-AnonymousAuth $Name Write-Head 'Проверка' $url = "http://localhost:$LocalPort/$Name/odata/standard.odata" try { Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop | Out-Null Write-Ok "Отвечает без пароля: $url" } catch [System.Net.WebException] { $code = [int]$_.Exception.Response.StatusCode if ($code -eq 401) { # Это успех: интерфейс поднялся и требует пользователя базы. Write-Ok "Интерфейс OData работает и запрашивает пароль: $url" } else { Write-Bad "Ответ $code по адресу $url" Write-Info 'Если 500 — не хватает компонентов ISAPI в IIS.' Write-Info 'Если 404 — публикация не подхватилась, перезапустите IIS: iisreset' } } Write-Head 'Что дальше' Write-Info "В самой базе «$($db.Name)» нужно включить интерфейс OData и завести" Write-Info 'служебного пользователя: Администрирование → Синхронизация данных →' Write-Info 'Настройки стандартного интерфейса OData. Инструкция: finexpert66.ru/setup' } function Enable-StandardOdata($vrdPath) { # Дописать в файл публикации разрешение на стандартный интерфейс OData. # # Конфигуратор ставит это галочкой, webinst — нет. Без элемента # standardOdata публикация работает, а адрес /odata/standard.odata # отдаёт 404: самая обидная ошибка на этом шаге. # if (-not (Test-Path $vrdPath)) { throw "Файл публикации не создан: $vrdPath" } [xml]$vrd = Get-Content $vrdPath -Encoding UTF8 $ns = 'http://v8.1c.ru/8.2/virtual-resource-system' $existing = $vrd.point.standardOdata if ($existing) { $existing.SetAttribute('enable', 'true') } else { $node = $vrd.CreateElement('standardOdata', $ns) $node.SetAttribute('enable', 'true') $node.SetAttribute('reuseSessions', 'autouse') $node.SetAttribute('sessionMaxAge', '20') $node.SetAttribute('poolSize', '10') $node.SetAttribute('poolTimeout', '5') $vrd.point.AppendChild($node) | Out-Null } $vrd.Save($vrdPath) Write-Ok 'Стандартный интерфейс OData разрешён в публикации' } function Get-AppPoolName($appName) { # Настоящее имя пула приложений, под которым работает публикация. # # Угадывать нельзя: webinst кладёт приложение либо в DefaultAppPool, # либо в пул со своим именем — как решит IIS. Права же нужно выдавать # именно той учётной записи, под которой пул реально работает. # try { Import-Module WebAdministration -ErrorAction Stop $app = Get-WebApplication -Site 'Default Web Site' -Name $appName -ErrorAction Stop if ($app -and $app.applicationPool) { return $app.applicationPool } } catch { Write-Info "Имя пула через IIS выяснить не удалось: $($_.Exception.Message)" } return 'DefaultAppPool' } function Grant-DatabaseAccess($dbPath, $appName) { # Права на каталог базы для учётной записи пула приложений. # # Пул работает не от вашего имени, а от служебной записи, у которой прав # на диск D: нет. Проявляется как ошибка открытия информационной базы # при верном пути — и выглядит совершенно загадочно. # $pool = Get-AppPoolName $appName Write-Info "Пул приложений: $pool" # Кандидаты по порядку убывания точности. Учётная запись пула # существует только пока пул есть; IIS_IUSRS — группа, в которую # входят все пулы, и она есть всегда. $identities = @("IIS AppPool\$pool", 'IIS_IUSRS') $granted = $false foreach ($identity in $identities) { # icacls — внешняя программа: об ошибке она сообщает кодом возврата # и текстом, а не исключением. Раньше скрипт этого не замечал и # отчитывался об успехе там, где права не выданы. $output = & icacls $dbPath /grant "${identity}:(OI)(CI)(M)" /T /C 2>&1 if ($LASTEXITCODE -eq 0 -and $output -notmatch 'Сопоставление|mapping between account') { Write-Ok "Доступ к каталогу базы выдан: $identity" $granted = $true break } Write-Info "Не подошло: $identity" } if (-not $granted) { Write-Bad "Права на каталог базы выдать не удалось: $dbPath" Write-Info 'Выдайте вручную: свойства каталога → Безопасность → Изменить →' Write-Info 'Добавить → IIS_IUSRS → разрешить «Изменение».' } } function Set-AnonymousAuth($appName) { # Анонимный доступ на уровне IIS. # # Пользователя проверяет сама 1С по логину и паролю базы. Если IIS # включит проверку Windows-пользователя, до 1С запрос не дойдёт вовсе — # вернётся 401 ещё на входе. # try { Import-Module WebAdministration -ErrorAction Stop $path = "IIS:\Sites\Default Web Site\$appName" Set-WebConfigurationProperty -Filter '/system.webServer/security/authentication/anonymousAuthentication' ` -Name enabled -Value $true -PSPath $path -ErrorAction Stop Set-WebConfigurationProperty -Filter '/system.webServer/security/authentication/windowsAuthentication' ` -Name enabled -Value $false -PSPath $path -ErrorAction SilentlyContinue Write-Ok 'Анонимный доступ на уровне IIS включён' } catch { Write-Info 'Настройки аутентификации оставлены как есть — проверим по ответу сервера.' } } function Invoke-Verify { if (-not $Name) { throw 'Укажите имя публикации: -Name kryukova' } Write-Head "Проверка доступа к базе через OData" $url = "http://localhost:$LocalPort/$Name/odata/standard.odata" Write-Info $url $login = Read-Host 'Пользователь базы' $secret = Read-Host 'Пароль' -AsSecureString $plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto( [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret)) $pair = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${login}:${plain}")) try { $response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 30 ` -Headers @{ Authorization = "Basic $pair" } -ErrorAction Stop Write-Ok "Доступ есть, код $($response.StatusCode)" # В ответе перечислены опубликованные объекты — по их числу видно, # отмечен ли состав на вкладке «Состав». $count = ([regex]::Matches($response.Content, '' } # --- Служба --------------------------------------------------------------- function Invoke-Service { if ($RemotePort -le 0) { throw 'Укажите порт, который мы вам сообщили: -RemotePort 18201' } if (-not (Test-Path $KeyPath)) { throw 'Сначала выполните шаг key' } if (-not (Test-Admin)) { throw 'Нужен запуск от имени администратора' } # Права выставляются ещё раз: ключ мог быть создан прежней версией # скрипта, где владельцем оставался человек, а не служба. Set-KeyPermissions Write-Head 'Проверка соединения' # -R пробрасывает наш локальный порт на сервер, -N не запускает команд. # ExitOnForwardFailure заставляет ssh упасть, если порт занят, — иначе # туннель «работает», а данных нет. $sshArgs = @( '-i', "`"$KeyPath`"", '-N', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=3', # Пакеты живости на уровне TCP: помогают, когда обрыв произошёл # молча — например, маршрутизатор потерял состояние соединения. '-o', 'TCPKeepAlive=yes', '-R', "${RemotePort}:127.0.0.1:$LocalPort", "$User@$Server" ) # Вывод ssh уходит в файл: по нему видно настоящую причину отказа. # Раньше скрипт валил всё на «не добавили ключ», хотя чаще дело в другом. $log = Join-Path $env:TEMP 'finexpert-tunnel-test.log' $test = Start-Process -FilePath 'ssh' -ArgumentList $sshArgs -PassThru -NoNewWindow ` -RedirectStandardError $log Start-Sleep -Seconds 6 if ($test.HasExited) { $reason = if (Test-Path $log) { Get-Content $log -Raw } else { '' } Write-Bad 'Соединение не установилось.' if ($reason -match 'remote port forwarding failed') { Write-Info 'Сервер отказал в пробросе порта. Причина на нашей стороне:' Write-Info 'либо порт уже занят другим туннелем, либо ключу не разрешён' Write-Info 'именно этот порт. Пришлите нам номер порта — поправим.' } elseif ($reason -match 'Permission denied|publickey') { Write-Info 'Ключ сервером не принят — мы его ещё не добавили.' Write-Info 'Пришлите нам вывод команды: .\tunnel-setup.ps1 -Step key' } elseif ($reason -match 'Connection timed out|Connection refused|No route') { Write-Info 'До сервера не достучаться. Обычно это запрет исходящих' Write-Info 'подключений по 22-му порту в вашем брандмауэре или у провайдера.' } elseif ($reason) { Write-Info "Ответ сервера: $($reason.Trim())" } return } Write-Ok 'Соединение установлено' Stop-Process -Id $test.Id -Force Write-Head 'Создание службы' # Задача планировщика вместо службы: не нужны сторонние обёртки, а # перезапуск при обрыве настраивается штатно. # Планировщик перенаправлять вывод не умеет, поэтому запуск идёт через # cmd: без журнала причина обрыва не видна вовсе, а обрывов у туннеля # хватает — от перезагрузки маршрутизатора до смены адреса. $logPath = "$env:ProgramData\FinExpert\tunnel.log" $command = "ssh.exe $($sshArgs -join ' ') >> `"$logPath`" 2>&1" $action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument "/c $command" # Два запуска: при включении компьютера и каждые пять минут. # Повторяющийся нужен на случай, когда ssh не упал, а завис: связь мертва, # процесс жив. Так было у клиента — за десять минут ни одной попытки # подключиться, туннель пришлось поднимать руками. $atStartup = New-ScheduledTaskTrigger -AtStartup $repeating = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` -RepetitionInterval (New-TimeSpan -Minutes 5) $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest # StopExisting вместо IgnoreNew: очередной запуск вытесняет зависший # экземпляр. С IgnoreNew зависший процесс блокировал перезапуск навсегда — # планировщик считал, что задача работает, и новых попыток не делал. # Живому туннелю это не мешает: пока связь есть, запуск раз в пять минут # поднимает соединение заново за секунду, а данные идут по нему же. $settings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -RestartCount 999 ` -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit ([TimeSpan]::Zero) ` -MultipleInstances StopExisting Register-ScheduledTask -TaskName $TaskName -Action $action ` -Trigger @($atStartup, $repeating) ` -Principal $principal -Settings $settings -Force | Out-Null Start-ScheduledTask -TaskName $TaskName Write-Ok "Туннель запущен и будет подниматься сам при перезагрузке" Write-Info "Порт на нашей стороне: $RemotePort" Write-Head 'Готово' Write-Info 'Напишите нам — проверим доступ со своей стороны и запустим выгрузку.' } function Invoke-Status { Write-Head 'Состояние туннеля' $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if (-not $task) { Write-Bad 'Задача не создана — выполните шаг service' return } $info = Get-ScheduledTaskInfo -TaskName $TaskName Write-Info "Состояние: $($task.State)" Write-Info "Последний запуск: $($info.LastRunTime)" Write-Info "Код возврата: $($info.LastTaskResult)" $ssh = Get-Process ssh -ErrorAction SilentlyContinue if ($ssh) { Write-Ok "Процесс ssh работает (PID $($ssh.Id -join ', '))" } else { Write-Bad 'Процесса ssh нет — туннель не поднят' } $logPath = "$env:ProgramData\FinExpert\tunnel.log" if (Test-Path $logPath) { Write-Head 'Последние записи журнала' Get-Content $logPath -Tail 10 | ForEach-Object { Write-Info $_ } } } function Invoke-Remove { Write-Head 'Отключение туннеля' Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Get-Process ssh -ErrorAction SilentlyContinue | Stop-Process -Force Write-Ok 'Туннель отключён. Ключ оставлен на месте — удалите вручную, если нужно:' Write-Info $KeyPath } switch ($Step) { 'check' { Invoke-Check } 'publish' { Invoke-Publish } 'verify' { Invoke-Verify } 'key' { Invoke-Key } 'service' { Invoke-Service } 'status' { Invoke-Status } 'remove' { Invoke-Remove } }