Skip to content

Instantly share code, notes, and snippets.

@Albus
Last active October 30, 2025 15:04
Show Gist options
  • Select an option

  • Save Albus/6656d721c230a27c056575b78c4e3c61 to your computer and use it in GitHub Desktop.

Select an option

Save Albus/6656d721c230a27c056575b78c4e3c61 to your computer and use it in GitHub Desktop.
Континент-АП vpn-клиент для macOS 26.1
#!/usr/bin/env -S -- sudo --prompt пароль: --bell -- pwsh -NoLogo -NoProfile
#Requires -Version 7.4
#Requires -RunAsAdministrator
#Requires -PSEdition Core
# скрипт предназначен для macOS 26
# Проверка ОС и версии PowerShell
if (-not $IsMacOS) {
Write-Error "СКРИПТ ТОЛЬКО ДЛЯ macOS!" -ForegroundColor Red
Write-Host "Текущая система: $($PSVersionTable.OS)" -ForegroundColor Yellow
Write-Host "Платформа: $($PSVersionTable.Platform)" -ForegroundColor Yellow
exit 1
}
# Дополнительная проверка версии macOS (если нужно)
$osVersion = sw_vers -productVersion
Write-Host "macOS версия: $osVersion" -ForegroundColor Cyan
# Проверяем что запускаем из sudo но не от root
if ($null -eq $env:SUDO_USER -and $env:USER -ne "root") {
Write-Host "Программа должна запускаться через sudo. Перезапускаем с sudo..." -ForegroundColor Yellow
$scriptPath = $MyInvocation.MyCommand.Path
sudo pwsh -File $scriptPath @args
exit $LASTEXITCODE
}
if ($env:USER -eq "root" -and $null -eq $env:SUDO_USER) {
Write-Error "Не запускайте напрямую от root, используйте sudo от обычного пользователя"
exit 1
}
# Полный путь к демону
$DaemonPath = "/usr/local/share/cts/bin/ctsd"
$ClientPath = "/usr/local/bin/cts"
$PidFile = "/var/run/ctsd.pid"
# Упрощенная функция для остановки процессов через PowerShell
function Stop-ProcessSafe {
param(
[int[]]$ProcessIds,
[string]$ProcessName,
[string]$FilePath
)
$results = @()
# Останавливаем по ProcessIds
if ($ProcessIds) {
foreach ($ppid in $ProcessIds) {
try {
Write-Host "Останавливаем процесс PID: $ppid"
Stop-Process -Id $ppid -Force -ErrorAction Stop
Write-Host "Процесс $ppid остановлен" -ForegroundColor Green
$results += @{
ProcessId = $ppid
Success = $true
}
}
catch [System.InvalidOperationException] {
# Процесс уже завершен - это не ошибка
Write-Host "Процесс $ppid уже завершен" -ForegroundColor Gray
$results += @{
ProcessId = $ppid
Success = $true
}
}
catch {
Write-Warning "Не удалось остановить процесс $ppid : $($_.Exception.Message)"
$results += @{
ProcessId = $ppid
Success = $false
Error = $_.Exception.Message
}
}
}
}
# Останавливаем по имени процесса
if ($ProcessName) {
try {
$processes = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue
foreach ($process in $processes) {
try {
Write-Host "Останавливаем процесс: $($process.ProcessName) (PID: $($process.Id))"
Stop-Process -Id $process.Id -Force -ErrorAction Stop
Write-Host "Процесс $($process.ProcessName) остановлен" -ForegroundColor Green
$results += @{
ProcessId = $process.Id
ProcessName = $process.ProcessName
Success = $true
}
}
catch [System.InvalidOperationException] {
Write-Host "Процесс $($process.ProcessName) уже завершен" -ForegroundColor Gray
$results += @{
ProcessId = $process.Id
ProcessName = $process.ProcessName
Success = $true
}
}
catch {
Write-Warning "Не удалось остановить процесс $($process.ProcessName): $($_.Exception.Message)"
$results += @{
ProcessId = $process.Id
ProcessName = $process.ProcessName
Success = $false
Error = $_.Exception.Message
}
}
}
}
catch {
Write-Debug "Процессы с именем $ProcessName не найдены"
}
}
return $results
}
# Функция для расширенного запуска процесса
function Start-ProcessExtended {
param(
[string]$FilePath,
[string]$ArgumentList = "",
[string]$WorkingDirectory = "",
[hashtable]$EnvironmentVariables = @{},
[switch]$WaitForExit,
[int]$TimeoutSeconds = 30
)
$processInfo = New-Object System.Diagnostics.ProcessStartInfo
$processInfo.FileName = $FilePath
$processInfo.Arguments = $ArgumentList
$processInfo.RedirectStandardOutput = $true
$processInfo.RedirectStandardError = $true
$processInfo.UseShellExecute = $false
$processInfo.CreateNoWindow = $true
if ($WorkingDirectory) {
$processInfo.WorkingDirectory = $WorkingDirectory
}
# Добавляем переменные окружения
foreach ($key in $EnvironmentVariables.Keys) {
$processInfo.EnvironmentVariables[$key] = $EnvironmentVariables[$key]
}
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $processInfo
Write-Host "Запуск процесса: $FilePath $ArgumentList" -ForegroundColor Gray
try {
$started = $process.Start()
if (-not $started) {
throw "Не удалось запустить процесс"
}
if ($WaitForExit) {
$completed = $process.WaitForExit($TimeoutSeconds * 1000)
if (-not $completed) {
Write-Warning "Процесс не завершился за $TimeoutSeconds секунд, принудительное завершение"
try {
$process.Kill()
$process.WaitForExit(5000)
}
catch {
# Игнорируем ошибки при завершении
}
return @{
Success = $false
ExitCode = -1
Output = ""
Error = "Таймаут выполнения"
}
}
$output = $process.StandardOutput.ReadToEnd()
$errorOutput = $process.StandardError.ReadToEnd()
return @{
Success = ($process.ExitCode -eq 0)
ExitCode = $process.ExitCode
Output = $output.Trim()
Error = $errorOutput.Trim()
}
} else {
# Для фоновых процессов
Start-Sleep -Milliseconds 500
return @{
Success = $true
ProcessId = $process.Id
}
}
}
catch {
Write-Error "Ошибка запуска процесса: $($_.Exception.Message)"
return @{
Success = $false
ExitCode = -1
Output = ""
Error = $_.Exception.Message
}
}
finally {
if (-not $WaitForExit) {
try {
$process.Dispose()
}
catch {
# Игнорируем ошибки при очистке
}
}
}
}
# Функция для выполнения команды от имени пользователя
function Invoke-AsUser {
param(
[string]$Command,
[string]$Arguments = "",
[string]$User = $env:SUDO_USER
)
Write-Host "Выполнение от пользователя $User : $Command $Arguments" -ForegroundColor Gray
try {
# Используем Start-Process для запуска от имени пользователя
$processInfo = New-Object System.Diagnostics.ProcessStartInfo
$processInfo.FileName = "sudo"
$processInfo.Arguments = "-u $User $Command $Arguments"
$processInfo.RedirectStandardOutput = $true
$processInfo.RedirectStandardError = $true
$processInfo.UseShellExecute = $false
$processInfo.CreateNoWindow = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $processInfo
$process.Start() | Out-Null
$process.WaitForExit(30000) | Out-Null
$output = $process.StandardOutput.ReadToEnd()
$errorOutput = $process.StandardError.ReadToEnd()
return @{
Success = ($process.ExitCode -eq 0)
ExitCode = $process.ExitCode
Output = $output.Trim()
Error = $errorOutput.Trim()
}
}
catch {
Write-Error "Ошибка выполнения команды: $($_.Exception.Message)"
return @{
Success = $false
ExitCode = -1
Output = ""
Error = $_.Exception.Message
}
}
}
# Функция для поиска процессов по исполняемому файлу
function Get-ProcessesByExecutable {
param(
[string]$ExecutablePath
)
$processes = @()
try {
# Получаем все процессы
$allProcesses = Get-Process -ErrorAction SilentlyContinue
foreach ($process in $allProcesses) {
try {
# Проверяем путь к исполняемому файлу процесса
if ($process.Path -eq $ExecutablePath) {
$processes += $process
}
}
catch {
# Игнорируем процессы, к которым нет доступа
continue
}
}
}
catch {
Write-Debug "Ошибка при поиске процессов: $($_.Exception.Message)"
}
return $processes
}
# Функция для полного уничтожения демона
function Stop-Daemon {
Write-Host "Остановка демона..."
# Пытаемся остановить соединение от имени пользователя
$result = Invoke-AsUser -Command $ClientPath -Arguments "disconnect"
if (-not $result.Success) {
Write-Warning "Не удалось выполнить disconnect: $($result.Error)"
}
Start-Sleep -Seconds 2
# Пытаемся остановить демона
$result = Start-ProcessExtended -FilePath $DaemonPath -ArgumentList "stop" -WaitForExit -TimeoutSeconds 10
if (-not $result.Success) {
Write-Warning "Не удалось остановить демон: $($result.Error)"
}
Start-Sleep -Seconds 2
# Ищем процессы только по исполняемому файлу ctsd
$ctsdProcesses = @()
try {
$ctsdProcesses = Get-ProcessesByExecutable -ExecutablePath $DaemonPath
# Дополнительно ищем по имени процесса, если по пути не найдено
if ($ctsdProcesses.Count -eq 0) {
Write-Host "Поиск процессов демона по имени..."
$ctsdProcesses = Get-Process -Name "ctsd" -ErrorAction SilentlyContinue
}
}
catch {
Write-Debug "Ошибка при поиске процессов: $($_.Exception.Message)"
}
if ($ctsdProcesses) {
Write-Host "Найдены процессы демона ($($ctsdProcesses.Count)), останавливаем..." -ForegroundColor Yellow
# Останавливаем процессы через безопасную функцию
$processIds = $ctsdProcesses | ForEach-Object { $_.Id }
$stopResults = Stop-ProcessSafe -ProcessIds $processIds
$failedProcesses = $stopResults | Where-Object { -not $_.Success }
if ($failedProcesses) {
Write-Warning "Не удалось остановить некоторые процессы демона"
# Дополнительная проверка
Start-Sleep -Seconds 2
$remaining = Get-ProcessesByExecutable -ExecutablePath $DaemonPath
if ($remaining) {
Write-Error "Не удалось уничтожить все процессы демона"
foreach ($proc in $remaining) {
Write-Host "Оставшийся процесс: $($proc.Id) $($proc.ProcessName)"
}
}
} else {
Write-Host "Все процессы демона остановлены" -ForegroundColor Green
}
} else {
Write-Host "Процессы демона не найдены" -ForegroundColor Green
}
# Удаляем PID файл если существует
if (Test-Path $PidFile) {
try {
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
Write-Host "PID файл удален: $PidFile"
}
catch {
Write-Debug "Не удалось удалить PID файл: $($_.Exception.Message)"
}
}
}
function Find-InterfaceForIP {
param(
[Parameter(Mandatory=$true)]
[string]$IPAddress
)
Write-Host "🔍 Поиск интерфейса для IP: $IPAddress" -ForegroundColor Magenta
# Проверяем валидность IP
$validIP = [System.Net.IPAddress]::TryParse($IPAddress, [ref]$null)
if (-not $validIP) {
Write-Error "Некорректный IP адрес: $IPAddress"
return $null
}
# Метод 1: через route get (самый надежный на macOS)
Write-Host "`n📡 Метод 1: Анализ таблицы маршрутизации..." -ForegroundColor Cyan
$routeResult = & { route get $IPAddress 2>$null }
if ($LASTEXITCODE -eq 0) {
$interfaceLine = $routeResult | Select-String "interface:"
if ($interfaceLine) {
$interface = ($interfaceLine -split ":")[1].Trim()
Write-Host "✅ Найден интерфейс: $interface" -ForegroundColor Green
return $interface
}
}
# Метод 2: через анализ сетевых интерфейсов
Write-Host "`n🔧 Метод 2: Анализ сетевых интерфейсов..." -ForegroundColor Cyan
$interfaces = Get-NetIPInterface | Where-Object { $_.ConnectionState -eq "Connected" }
foreach ($if in $interfaces) {
$addresses = Get-NetIPAddress -InterfaceIndex $if.InterfaceIndex -AddressFamily IPv4 |
Where-Object { $_.IPAddress -ne "127.0.0.1" }
foreach ($addr in $addresses) {
# Упрощенная проверка принадлежности к подсети
$networkInfo = "$($addr.IPAddress)/$($addr.PrefixLength)"
Write-Host " 🔍 Проверка $networkInfo..." -ForegroundColor Gray
# Здесь можно добавить более сложную логику проверки подсети
if ($addr.IPAddress -eq $IPAddress) {
Write-Host "✅ IP принадлежит интерфейсу: $($if.InterfaceAlias)" -ForegroundColor Green
return $if.InterfaceAlias
}
}
}
Write-Host "❌ Не удалось определить интерфейс для $IPAddress" -ForegroundColor Red
return $null
}
# Быстрая функция проверки соединения через .NET Ping (самый быстрый метод)
function Test-ConnectionFast {
param(
[int]$TimeoutMs = 800 # Уменьшенный таймаут для быстрой проверки
)
$target = "10.197.64.6"
try {
$ping = New-Object System.Net.NetworkInformation.Ping
$reply = $ping.Send($target, $TimeoutMs)
$result = ($reply -and $reply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success)
if ($result) {
return @{
Success = $true
ResponseTime = $reply.RoundtripTime
}
} else {
return @{
Success = $false
ResponseTime = 0
}
}
}
catch {
return @{
Success = $false
ResponseTime = 0
Error = $_.Exception.Message
}
}
finally {
try { if ($ping) { $ping.Dispose() } } catch { }
}
}
# Основная функция проверки соединения (быстрая версия)
function Test-Connection {
$result = Test-ConnectionFast -TimeoutMs 500
return $result.Success
}
# Функция для установки соединения
function Connect-VPN {
Write-Host "Установка VPN соединения..." -ForegroundColor Cyan
# Сначала останавливаем демона (из-за бага)
Stop-Daemon
# Запускаем демона в фоновом режиме
Write-Host "Запуск демона VPN..." -ForegroundColor Cyan
$result = Start-ProcessExtended -FilePath $DaemonPath -WaitForExit -TimeoutSeconds 10
if (-not $result.Success) {
Write-Warning "Демон завершился с ошибкой: $($result.Error)"
} else {
Write-Host "Демон запущен успешно" -ForegroundColor Green
}
Start-Sleep -Seconds 3 # Уменьшено с 5 до 3 секунд
# Устанавливаем соединение от имени пользователя
Write-Host "Установка VPN соединения..." -ForegroundColor Cyan
$result = Invoke-AsUser -Command $ClientPath -Arguments "connect"
if ($result.Success) {
Write-Host "Команда connect выполнена успешно" -ForegroundColor Green
if ($result.Output) {
Write-Host "Вывод: $($result.Output)" -ForegroundColor Gray
}
} else {
Write-Warning "Команда connect завершилась с ошибкой: $($result.Error)"
}
Start-Sleep -Seconds 2 # Уменьшено с 3 до 2 секунд
# Проверяем соединение
if (Test-Connection) {
Write-Host "VPN соединение установлено успешно" -ForegroundColor Green
return $true
} else {
Write-Warning "Соединение установлено, но DNS недоступен"
return $false
}
}
# Обработчик Ctrl+C
try {
$null = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {
Write-Host "`nЗавершение работы..." -ForegroundColor Yellow
Stop-Daemon
exit 0
}
}
catch {
Write-Warning "Не удалось зарегистрировать обработчик exiring: $($_.Exception.Message)"
}
# Обработчик SIGTERM (сигнал завершения)
try {
$null = Register-EngineEvent -SourceIdentifier Signal:Terminate -Action {
Write-Host "`nПолучен сигнал завершения (SIGTERM)..." -ForegroundColor Yellow
$Global:IsShuttingDown = $true
Stop-Daemon
exit 0
}
}
catch {
Write-Warning "Не удалось зарегистрировать обработчик SIGTERM: $($_.Exception.Message)"
}
function Remove-DNSFromSingleResolv {
param(
[string]$FilePath,
[string]$DNSToRemove
)
if (-not (Test-Path $FilePath)) {
return
}
$content = Get-Content $FilePath
$originalCount = ($content | Where-Object { $_ -match "^nameserver" }).Count
# Фильтруем DNS серверы
$newContent = $content | Where-Object { $_ -notmatch "^nameserver\s+$DNSToRemove$" }
$newCount = ($newContent | Where-Object { $_ -match "^nameserver" }).Count
if ($originalCount -eq $newCount) {
return
}
# Создаем бэкап
$backupPath = "$FilePath.backup.$(Get-Date -Format 'HHmmss')"
Copy-Item $FilePath $backupPath
# Сохраняем изменения
$newContent | Out-File $FilePath -Encoding ASCII
$removedCount = $originalCount - $newCount
Write-Host "✅ Удалено $removedCount вхождений DNS из $FilePath" -ForegroundColor Green
}
function Remove-DNSFromAllResolvFiles {
param(
[Parameter(Mandatory=$true)]
[string]$DNSToRemove
)
if ((id -u) -ne 0) {
Write-Host "🔒 Требуются права root" -ForegroundColor Red
return
}
# Список возможных расположений resolv.conf
$resolvPaths = @(
"/var/run/resolv.conf",
"/etc/resolv.conf",
"/private/var/run/resolv.conf"
)
foreach ($path in $resolvPaths) {
if (Test-Path $path) {
$realPath = $path
# Проверяем симлинки
$item = Get-Item $path -ErrorAction SilentlyContinue
if ($item -and $item.LinkType -eq "SymbolicLink") {
$realPath = $item.Target
}
# Удаляем DNS из файла
Remove-DNSFromSingleResolv -FilePath $realPath -DNSToRemove $DNSToRemove
}
}
}
# Основной цикл программы
try {
Write-Host "Запуск VPN монитора..." -ForegroundColor Yellow
Write-Host "Для выхода нажмите Ctrl+C" -ForegroundColor Yellow
Write-Host "Быстрый мониторинг соединения (1 сек)" -ForegroundColor Cyan
while ($true) {
# Устанавливаем соединение
$connected = Connect-VPN
if (-not $connected) {
Write-Host "Попытка переподключения через 3 секунды..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
continue
}
# Мониторинг соединения
$interface = Find-InterfaceForIP -IPAddress "10.197.64.6"
ifconfig $interface
"10.99.0.35","10.197.64.6" | ForEach-Object { Remove-DNSFromAllResolvFiles -DNSToRemove $_ }
Write-Host "🧹 Очистка DNS кэша..." -ForegroundColor Yellow
dscacheutil -flushcache
killall -HUP mDNSResponder
Write-Host "Мониторинг соединения 10.197.64.6..." -ForegroundColor Cyan
$failureCount = 0
$successCount = 0
$lastStatus = $true
while ($true) {
# Быстрая проверка каждую секунду
Start-Sleep -Seconds 1
$connectionResult = Test-ConnectionFast -TimeoutMs 800
$isConnected = $connectionResult.Success
if (-not $isConnected) {
$failureCount++
$successCount = 0
# Выводим предупреждение только при изменении статуса или каждые 5 неудач
if ($lastStatus -or $failureCount -eq 1 -or $failureCount % 5 -eq 0) {
Write-Warning "Потеряно соединение с DNS ($failureCount/3)"
}
if ($failureCount -ge 3) {
Write-Host "Перезапуск VPN соединения..." -ForegroundColor Yellow
break
}
} else {
$successCount++
# Выводим сообщение о восстановлении только при изменении статуса
if (-not $lastStatus) {
Write-Host "Соединение восстановлено (ping: $($connectionResult.ResponseTime)ms)" -ForegroundColor Green
}
# Периодический статус (каждые 30 успешных проверок)
if ($successCount % 30 -eq 0) {
"10.99.0.35","10.197.64.6" | ForEach-Object { Remove-DNSFromAllResolvFiles -DNSToRemove $_ }
Write-Host "Соединение стабильно - $(Get-Date -Format 'HH:mm:ss') (ping: $($connectionResult.ResponseTime)ms)" -ForegroundColor Gray
}
$failureCount = 0
}
$lastStatus = $isConnected
}
Write-Host "Переподключение через 2 секунды..." -ForegroundColor Yellow
Start-Sleep -Seconds 2
}
}
catch {
Write-Error "Критическая ошибка: $_"
try {
Stop-Daemon
}
catch {
Write-Host "Ошибка при остановке демона: $($_.Exception.Message)" -ForegroundColor Red
}
exit 1
}
@Albus
Copy link
Author

Albus commented Oct 29, 2025

brew install powershell
chmod +x ./vpn.ps1
./vpn.ps1

@Albus
Copy link
Author

Albus commented Oct 29, 2025

скрипт устанавливает соединение по-умолчанию и поддерживает его

image

@Albus
Copy link
Author

Albus commented Oct 30, 2025

скрипт очищает неверные настройки dns, установленные vpn-клиентом

для разрешения имен нужно 1 раз создать настройку резолвера

sudo mkdir -p /etc/resolver
sudo tee /etc/resolver/domain.local > /dev/null << EOF
nameserver 1.1.1.1
nameserver 1.0.0.1
timeout 5
EOF

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment