# Диагноз связи с сервером Терила для машины, где стоит Базис. # # Зачем он есть. Между «скачал агента» и «подключился» теряется почти всё, # и человеку в этот момент видна только строка ошибки. Скрипт проходит путь # до сервера по шагам и называет ПЕРВОЕ место, где путь оборвался. # # Чего он НЕ делает: не правит и не выключает правила брандмауэра, не трогает # прокси и сертификаты, ничего никуда не отправляет. Правило поставлено # намеренно, что ещё оно держит -- неизвестно, и вставать между человеком # и его настройкой мы не будем. Только читаем и объясняем. # # Запускается однострочником `irm https://teril.ru/doctor.ps1 | iex` -- он # обходит RemoteSigned: скачанный неподписанный .ps1 иначе просто не стартует. # Права администратора не нужны: всё, что читается, читается без них. # Адрес сервера вписывает кабинет при раздаче -- ровно так же, как в агента # (src/web/http.ts, sendDoctor). В репозитории маркер пуст. $BakedUrl = 'https://teril.ru' $ErrorActionPreference = 'Stop' Set-StrictMode -Off $Url = if ($BakedUrl) { $BakedUrl } else { 'https://teril.ru' } $TerilDir = 'C:/ProgramData/Teril' $Lines = New-Object System.Collections.ArrayList function Say([string]$text, [string]$color = 'Gray') { Write-Host $text -ForegroundColor $color [void]$Lines.Add($text) } function Head([string]$text) { Say '' ; Say $text 'White' } function Ok([string]$text) { Say " [ок] $text" 'Green' } function Bad([string]$text) { Say " [нет] $text" 'Red' } function Warn([string]$text) { Say " [!] $text" 'Yellow' } function Info([string]$text) { Say " $text" 'Gray' } # Первое объяснение и есть диагноз: дальше идти незачем, всё следующее # сломано тем же самым. Копим их, но выводим один -- самый ранний. $Diagnosis = $null function Diagnose([string]$text) { if (-not $script:Diagnosis) { $script:Diagnosis = $text } } $uri = [Uri]$Url $targetHost = $uri.Host $port = if ($uri.Port -gt 0) { $uri.Port } else { if ($uri.Scheme -eq 'https') { 443 } else { 80 } } Say '================================================================' Say ' Терил: проверка связи с сервером' Say " Сервер: $Url" Say " Время: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Say '================================================================' # --- 1. Обстановка --------------------------------------------------------- Head '1. Машина' try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop Info "$($os.Caption) ($($os.Version)), $($env:COMPUTERNAME)" } catch { Info "Windows: определить не вышло ($($_.Exception.Message))" } Info "PowerShell $($PSVersionTable.PSVersion)" $cfgFile = Join-Path $TerilDir 'server.json' $logFile = Join-Path $TerilDir 'agent.log' if (Test-Path $cfgFile) { try { $cfg = Get-Content $cfgFile -Raw -Encoding UTF8 | ConvertFrom-Json Ok "настройки агента: $cfgFile" if ($cfg.url) { Info "в них адрес сервера: $($cfg.url)" # Проверяем ТОТ адрес, куда ходит агент, а не тот, откуда скачан # скрипт: разойдись они -- мы бы проверили чужую дорогу. if ($cfg.url -ne $Url) { Warn "агент ходит по другому адресу -- проверяем его" $Url = $cfg.url $uri = [Uri]$Url $targetHost = $uri.Host $port = if ($uri.Port -gt 0) { $uri.Port } else { if ($uri.Scheme -eq 'https') { 443 } else { 80 } } } } if ($cfg.token -and $cfg.token -ne 'CHANGE_ME') { # НЕ ТОКЕН, А ЕГО ХВОСТ -- самая частая беда входа. В кабинете, # в таблице мест, токена нет: там последние знаки, по которым место # узнают. Человек копирует их, вставляет в форму -- и получает 401, # то есть «токен не признан», на то, что токеном и не было. # Отсюда это видно без Базиса, одной строкой. if ($cfg.token.Length -lt 16 -or $cfg.token -match '\s|…') { Bad "в настройках лежит не токен, а обрывок ($($cfg.token.Length) знаков)" Diagnose ('В кабинете, в таблице мест, виден только ХВОСТ токена -- последние знаки, ' + 'по которым место узнают. Войти им нельзя. Сам токен длинный (начинается с bz_) ' + 'и показывается ОДИН раз, при выдаче: нажмите в кабинете «Новый токен» ' + 'и вставьте в форму агента то, что он покажет.') } else { Info 'токен места сохранён' } } else { Warn 'токена места в настройках нет -- агент спросит его при запуске' } } catch { Warn "настройки $cfgFile не читаются: $($_.Exception.Message)" } } else { Warn "настроек $cfgFile нет" } if (Test-Path $logFile) { try { $tail = @(Get-Content $logFile -Tail 200 -Encoding UTF8) $errs = @($tail | Where-Object { $_ -match 'ERROR' } | Select-Object -Last 5) if ($errs.Count -gt 0) { Info 'последние ошибки из журнала агента:' foreach ($e in $errs) { Info " $e" } } else { Ok 'в журнале агента свежих ошибок нет' } # ЖДЁТ ВВОДА -- не поломка, и потому мимо проверки ошибок. А выглядит # она как поломка: агент в этом состоянии не ходит на сервер вовсе, # и связь проверять не в чем. Разбор 2026-09-10 (место 192) стоил # дня переписки ровно на этом: журнал молчал, релей не видел запросов, # и оба молчания читались как «скрипт умер». $awaits = -1; $works = -1 for ($i = 0; $i -lt $tail.Count; $i++) { if ($tail[$i] -match 'жду токен места') { $awaits = $i } if ($tail[$i] -match 'токен принят|сессия ') { $works = $i } } if ($awaits -ge 0 -and $awaits -gt $works) { Warn 'агент ЖДЁТ ТОКЕН: он запущен, но на сервер ещё не ходил' Diagnose ('Агент ждёт токен рабочего места: в форме Базиса на месте просьбы стоит поле ' + 'для него. Вставьте туда токен -- он выдаётся в кабинете, ОДИН РАЗ при выдаче; ' + 'потеряли -- выпустите новый. Пока токена нет, агент на сервер не ходит вовсе, ' + 'и связь тут ни при чём.') } } catch { Info "журнал агента не читается: $($_.Exception.Message)" } } else { Bad "журнала $logFile нет: агент ни разу не запускался на этой машине" Diagnose ("Агент ни разу не запускался: журнала $logFile нет вовсе. " + 'Проверка ниже говорит о машине, а не об агенте. Запустите teril-agent.js ' + 'в Базисе (редактор скриптов) -- и если он не подключится, ' + 'прогоните эту проверку снова.') } # --- 2. DNS ---------------------------------------------------------------- Head "2. Имя $targetHost" $addrs = @() if ($targetHost -as [Net.IPAddress]) { $addrs = @($targetHost) Info 'адрес числовой, DNS не нужен' } else { try { $addrs = @([Net.Dns]::GetHostAddresses($targetHost) | ForEach-Object { $_.IPAddressToString }) Ok "разрешается в $($addrs -join ', ')" } catch { Bad "не разрешается: $($_.Exception.Message)" Diagnose ("Имя $targetHost не разрешается в адрес. Это DNS или сети нет вовсе. " + 'Откройте в браузере любой сайт: не открывается ничего -- дело в сети машины, ' + 'открывается -- напишите нам, адрес мог смениться.') } } # --- 3. TCP ---------------------------------------------------------------- Head "3. Порт $port" $tcpOk = $false if ($addrs.Count -gt 0) { # TcpClient со своим таймаутом: быстрее Test-NetConnection и не требует прав. try { $client = New-Object Net.Sockets.TcpClient $iar = $client.BeginConnect($targetHost, $port, $null, $null) if ($iar.AsyncWaitHandle.WaitOne(6000, $false) -and $client.Connected) { $tcpOk = $true $client.EndConnect($iar) Ok "соединение открывается" } else { Bad "соединение не открывается за 6 с (таймаут)" } $client.Close() } catch { Bad "соединение отбито: $($_.Exception.Message)" } } if (-not $tcpOk -and $addrs.Count -gt 0) { Diagnose ("До $targetHost`:$port не достучался даже PowerShell. Наружу закрыто у всей машины: " + 'брандмауэр целиком, антивирус или сеть конторы. Смотрите шаг 7 -- там сказано, ' + 'какие правила нашлись.') } # --- 4. HTTPS -------------------------------------------------------------- Head '4. Ответ сервера' $httpOk = $false if ($tcpOk) { # Токен не спрашиваем вовсе: /health без него отвечает 401, и 401 -- ЭТО УСПЕХ. # Сервер достижим, TLS цел. 200 без токена не бывает. try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} try { $resp = Invoke-WebRequest -Uri "$Url/health" -UseBasicParsing -TimeoutSec 15 Warn "сервер ответил $($resp.StatusCode) без токена -- так быть не должно, но связь есть" $httpOk = $true } catch [Net.WebException] { $status = $null if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } if ($status -eq 401) { $httpOk = $true Ok 'сервер отвечает 401 без токена -- так и надо: связь и сертификат в порядке' } elseif ($status) { Bad "сервер ответил $status" Diagnose "Сервер отвечает $status вместо 401. Похоже, отвечает не он, а прокси или заглушка. Напишите нам." } else { Bad "ответа нет: $($_.Exception.Message)" Diagnose ('Порт открыт, а разговор не выходит. Так выглядит перехват соединения ' + 'антивирусом или прокси. Смотрите шаги 5 и 6.') } } catch { # PowerShell 7 бросает не WebException, и класс ошибки здесь другой; # ответ сервера в нём лежит там же, поэтому разбираем так же. $status = $null try { if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } } catch {} if ($status -eq 401) { $httpOk = $true Ok 'сервер отвечает 401 без токена -- так и надо: связь и сертификат в порядке' } elseif ($status) { Bad "сервер ответил $status" Diagnose "Сервер отвечает $status вместо 401. Похоже, отвечает не он, а прокси или заглушка. Напишите нам." } else { Bad "ответа нет: $($_.Exception.Message)" Diagnose ('Порт открыт, а разговор не выходит. Так выглядит перехват соединения ' + 'антивирусом или прокси. Смотрите шаги 5 и 6.') } } } else { Info 'пропущено: порт не открылся' } # --- 5. Сертификат --------------------------------------------------------- Head '5. Сертификат' if ($tcpOk -and $uri.Scheme -eq 'https') { $issuer = $null try { $client = New-Object Net.Sockets.TcpClient($targetHost, $port) $ssl = New-Object Net.Security.SslStream($client.GetStream(), $false, { $true }) $ssl.AuthenticateAsClient($targetHost) $cert = New-Object Security.Cryptography.X509Certificates.X509Certificate2($ssl.RemoteCertificate) $issuer = $cert.Issuer Info "выдан: $issuer" Info "годен до: $($cert.NotAfter)" $ssl.Dispose(); $client.Close() } catch { Warn "сертификат не прочитан: $($_.Exception.Message)" } if ($issuer) { if ($issuer -match "Let's Encrypt|R1[01]|E[56]") { Ok 'сертификат настоящий' } else { Bad 'сертификат подменён -- соединение перехватывают' # Браузер при этом молчит: корень антивируса он знает. Node внутри # Базиса корпоративному корню НЕ ДОВЕРЯЕТ и оборвёт соединение. Diagnose ("Сертификат сервера выдан не Let's Encrypt, а `"$issuer`". Соединение " + 'перехватывает антивирус или корпоративный прокси. Браузер молчит, потому что ' + 'знает их корень, а Базис -- нет, и обрывает связь. Исключите teril.ru ' + 'из проверки HTTPS в антивирусе.') } } } else { Info 'пропущено' } # --- 6. Прокси ------------------------------------------------------------- Head '6. Прокси' $proxyFound = $false try { $winhttp = netsh winhttp show proxy 2>$null | Out-String if ($winhttp -match 'Прямой доступ|Direct access') { Ok 'системного прокси нет (winhttp)' } else { $proxyFound = $true; Warn "winhttp: $(($winhttp -split "`n" | Where-Object { $_.Trim() } | Select-Object -Last 2) -join '; ')" } } catch { Info 'netsh недоступен' } try { $ie = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction Stop if ($ie.ProxyEnable -eq 1) { $proxyFound = $true Warn "прокси браузера включён: $($ie.ProxyServer)" } else { Ok 'прокси браузера выключен' } } catch { Info 'настройки браузера не прочитаны' } if ($proxyFound -and -not $httpOk) { Diagnose ('На машине настроен прокси, а агент ходит НАПРЯМУЮ -- он прокси не умеет. ' + 'Весь его трафик уходит в никуда и кончается таймаутом. Напишите нам: ' + 'через прокси мы пока не ходим.') } # --- 7. Брандмауэр и Базис ------------------------------------------------- Head '7. Базис и брандмауэр' $bazis = $null try { $proc = Get-Process -Name 'Bazis*' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($proc) { $bazis = $proc.Path } } catch {} if (-not $bazis) { foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { if (-not $root) { continue } $found = Get-ChildItem -Path $root -Filter 'Bazis.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if ($found) { $bazis = $found.FullName; break } } } if ($bazis) { Info "Базис: $bazis" } else { Warn 'Bazis.exe не найден -- ни запущенным, ни в обычных папках' } $blocking = @() try { # Чтение правил прав администратора не требует. $rules = @(Get-NetFirewallRule -Direction Outbound -Action Block -Enabled True -ErrorAction Stop) if ($bazis) { $leaf = Split-Path $bazis -Leaf foreach ($r in $rules) { $app = $null try { $app = ($r | Get-NetFirewallApplicationFilter -ErrorAction Stop).Program } catch {} if ($app -and ($app -eq $bazis -or (Split-Path $app -Leaf) -eq $leaf)) { $blocking += $r } } } if ($blocking.Count -gt 0) { foreach ($r in $blocking) { Bad "правило `"$($r.DisplayName)`" запрещает Базису исходящие (профиль: $($r.Profile))" } } else { Ok 'отдельного запрета исходящих для Базиса не нашлось' } } catch { Info "правила брандмауэра не прочитаны: $($_.Exception.Message)" } $blockAll = @() try { $blockAll = @(Get-NetFirewallProfile -ErrorAction Stop | Where-Object { $_.DefaultOutboundAction -eq 'Block' }) if ($blockAll.Count -gt 0) { foreach ($p in $blockAll) { Bad "профиль $($p.Name): исходящие запрещены по умолчанию" } } else { Ok 'исходящие по умолчанию разрешены' } } catch { Info 'профили брандмауэра не прочитаны' } # Ключевая развилка, ради которой всё и написано: PowerShell наружу ходит, # а Базису это запрещено правилом. Отсюда и берётся «скачал, но не подключился». if ($httpOk -and ($blocking.Count -gt 0 -or $blockAll.Count -gt 0)) { $names = if ($blocking.Count -gt 0) { ($blocking | ForEach-Object { "`"$($_.DisplayName)`" (профиль $($_.Profile))" }) -join ', ' } else { ($blockAll | ForEach-Object { "профиль $($_.Name), исходящие по умолчанию запрещены" }) -join ', ' } $Diagnosis = "Вот оно. Сам PowerShell до сервера доходит, а Базису выход наружу ЗАПРЕЩЁН: $names." } # --- Вывод ----------------------------------------------------------------- Say '' Say '================================================================' Say ' Что это значит' Say '================================================================' if (-not $Diagnosis) { if ($httpOk) { Say 'Связь в порядке: сервер отвечает, сертификат настоящий, запретов не нашлось.' 'Green' Say 'Если агент всё равно не подключается -- пришлите нам этот отчёт и agent.log.' } else { Say 'Связи нет, а причину назвать нечем: посмотрите строки [нет] выше и пришлите отчёт.' 'Yellow' } } else { Say $Diagnosis 'Yellow' } if ($httpOk -and ($blocking.Count -gt 0 -or $blockAll.Count -gt 0)) { Say '' Say 'Правило мы НЕ ТРОГАЕМ: его поставили намеренно, и что ещё оно держит -- знаете только вы.' Say 'Дальше два пути, выбирать вам:' Say '' Say ' 1) Разрешить Базису выход наружу самому. Нужны права администратора,' Say ' PowerShell «от имени администратора»:' if ($blocking.Count -gt 0) { foreach ($r in $blocking) { Say " Disable-NetFirewallRule -DisplayName `"$($r.DisplayName)`" # снять правило целиком" } } if ($bazis) { Say " New-NetFirewallRule -DisplayName 'Teril: Базис наружу' -Direction Outbound ``" Say " -Program '$bazis' -RemoteAddress $($addrs -join ',') -RemotePort $port ``" Say " -Protocol TCP -Action Allow # или сузить до нас одних" } Say '' Say ' 2) Написать нам -- разберёмся вместе. Отчёт уже сохранён, пришлите его.' } Say '' Say 'Оговорка: успех у PowerShell не доказывает, что заработает Базис -- он ходит' Say 'наружу сам по себе. Окончательная проверка одна: запустить агента в Базисе.' # Отчёт ложится файлом, чтобы его можно было прислать. Никуда сам он не уходит. $report = Join-Path $TerilDir 'doctor-report.txt' try { if (-not (Test-Path $TerilDir)) { New-Item -ItemType Directory -Path $TerilDir -Force | Out-Null } $Lines -join "`r`n" | Out-File -FilePath $report -Encoding UTF8 } catch { $report = Join-Path $env:TEMP 'teril-doctor-report.txt' try { $Lines -join "`r`n" | Out-File -FilePath $report -Encoding UTF8 } catch { $report = $null } } Say '' if ($report) { Write-Host "Отчёт сохранён: $report" -ForegroundColor Cyan }