Привет, Хабр!
Я начал с OpenVPN на одном виртуальном сервере и четырёх клиентских профилей. Постепенно появились Telegram-бот для управления доступом, отдельные сертификаты для устройств, несколько транспортов на Linux и самостоятельный VPN на Windows. По дороге случились переподключения каждые шестнадцать секунд, работающий TLS без работающего интернета, неудачное ускорение с откатом и путаница между старыми и новыми профилями.
Главная трудность была в том, что запущенный процесс и статус Connected ещё не означали работающий интернет. Несколько раз сервер выглядел исправным, а сайты на телефоне не открывались.
Сейчас VPN работает у всех, кому я предоставил доступ: профиль можно получить через бота, импортировать на телефоне или ноутбуке и пользоваться интернетом. Расскажу, как устроена система и что пришлось исправить. В статье есть команды Linux и PowerShell, настройки OpenVPN, выдача доступа через Telegram и код TCP-обработчика.
Это история личной инфраструктуры. Рабочие публичные адреса, внешние порты, имена семейных профилей и секреты в примерах заменены. 198.51.100.10 обозначает NL, 203.0.113.20 — Windows, а порты 5443, 5444 и 15444 выбраны для объяснения схемы. Эти адреса предназначены для документации и не являются точками подключения. Команды с vpn-lab, подсетями 10.77.0.0/24 и 10.78.0.0/24 — реконструкция существенных операций для отдельного стенда. Исторические результаты ниже относятся к моей установке; приведённый стенд целиком заново не разворачивался.
Художественное преувеличение: состав аварийной бригады у собственного VPN может быть скромнее.
Что мне было нужно
Мне нужен был VPN для телефона и ноутбука. Потом — ещё и для близких. Хотелось выдавать каждому свой профиль через Telegram и при необходимости отключать его отдельно, не меняя настройки у всех остальных.
Так к OpenVPN добавился Telegram-бот. Через него я создаю, отправляю и отзываю профили. Он работает на сервере, поэтому держать ноутбук включённым для этого не нужно.
У меня уже были два VPS — виртуальных сервера — в Нидерландах. В начале этой истории конфигурация выглядела так:
|
Узел |
ОС |
Ресурсы на том этапе |
Роль в VPN |
|---|---|---|---|
|
NL |
Ubuntu 24.04 |
4 vCPU, 4 ГБ RAM, 40 ГБ NVMe |
OpenVPN, позднее проверенные дополнительные серверные компоненты; Telegram-бот |
|
Windows |
Windows Server 2022 |
2 vCPU, 4 ГБ RAM, первоначально 25 ГБ NVMe |
Сначала дополнительный вход к NL, затем собственный OpenVPN |
На машинах выполнялись и другие задачи. Поэтому переустановка ОС, изменение основного маршрута или перезапуск всех служб сразу мне не подходили. Новый VPN я сначала поднимал рядом с действующей схемой.
В командах ниже BUE... и bue_... — служебные имена задач, служб и модулей моей установки. Они не относятся к отдельной версии OpenVPN.
Как менялась архитектура
Первая версия была обычной: устройство → OpenVPN на NL → NAT → интернет. Telegram-бот управления тоже работал на NL.
Позже Windows стала дополнительной точкой входа:
Устройство → TCP-посредник на Windows → OpenVPN на NL → NAT → интернет.
Посредник не расшифровывал VPN. Он принимал TCP-соединение и передавал байты отдельному экземпляру OpenVPN на NL. Если выключить NL, этот маршрут тоже перестанет работать. Два входных адреса ещё не создают два независимых VPN.
На следующем этапе появился самостоятельный Windows-маршрут:
Устройство → локальный TCP-обработчик Windows → OpenVPN на 127.0.0.1 → TAP → WinNAT → интернет.
Бот на NL управляет профилями Windows через SSH/SFTP.
В последней схеме пользовательский трафик не ходит через NL. При этом выдача нового профиля через бота по-прежнему зависит от управляющего узла NL. Уже импортированный профиль и возможность выпустить следующий профиль — разные зависимости.
Этап 1. Генератор профилей и первое меню
Первым удобным инструментом стал ovpn_make.sh: он собирал четыре профиля OpenVPN для разных устройств. Затем появились меню /vpn, создание, отправка и отзыв профиля, а также выбор iOS, Android или Windows. Теперь профиль можно было получить прямо в боте.
Генератору требовалось найти серверный конфиг, каталог Easy-RSA и ключ tls-crypt. Easy-RSA — утилита для выпуска сертификатов: сервер использует их, чтобы узнавать разрешённые устройства.
Первые исправления касались вполне земных вещей: Easy-RSA лежал глубже ожидаемого, а относительный путь к tls-crypt нужно было считать от каталога конфигурации, а не от текущего рабочего каталога скрипта.
Суть исправления можно показать отдельно:
# Пример разбора заранее выбранного серверного конфига.# Это не автоматический выбор «первого попавшегося» экземпляра.CONF=/etc/openvpn/server/lab.confCONFDIR=$(dirname "$CONF")TC=$(awk '$1 == "tls-crypt" { print $2; exit }' "$CONF")if [ -n "$TC" ] && [ "${TC:0:1}" != / ]; then TC="$CONFDIR/$TC"fitest -r "$TC" || { printf '%s\n' 'Не найден настроенный tls-crypt' >&2 exit 1}
Для простой строки в моём конфиге этого хватало. Полноценным парсером всех вариантов OpenVPN такой awk не является: кавычки, подключаемые файлы и несколько экземпляров требуют отдельного учёта.
После появления нескольких серверных конфигураций ещё важнее стало явно указывать, к какому экземпляру относится профиль. Иначе можно без единой синтаксической ошибки собрать файл с неправильным адресом, портом или центром сертификатов.
Этап 2. Именные профили и доставка через Telegram
Вместо четырёх общих файлов я перешёл к отдельным профилям устройств. В меню появились имя, например work-laptop или home-phone, проверка уникальности и отдельный отзыв.
Имя стало одновременно частью имени файла и CN сертификата. CN, или Common Name, — идентификатор в сертификате. У меня он обозначает конкретный профиль, а не фамилию человека и не тип операционной системы.
Основная проверка имени выглядела так:
import reimport unicodedatadef validate_ascii_name(value): value = unicodedata.normalize("NFKD", value.strip().lower()) value = "".join(c for c in value if not unicodedata.combining(c)) value = re.sub(r"[\s_]+", "-", value) value = re.sub(r"-+", "-", value).strip("-") if not re.fullmatch(r"[a-z0-9-]{3,24}", value): raise ValueError("Нужно 3–24 латинских символа, цифры или дефис") return value
В моём полном обработчике перед этим есть транслитерация русских букв. Здесь оставлена проверка уже латинского имени. Слишком длинное имя отклоняется целиком: молчаливое обрезание могло бы превратить два разных запроса в один идентификатор.
Само наличие корректного имени не разрешает создать профиль. Нужно проверить занятость имени в каталоге и PKI (системе ключей и сертификатов), действительность сертификата и принадлежность файла нужной установке. Существующий файл не должен перезаписываться при повторном запросе. В новых Windows-профилях эти проверки развиты дальше, чем в старом NL-генераторе; подробнее об этом ниже.
Почему отправить по нику оказалось сложнее одной кнопки
Изначальная идея была простой: создать профиль, указать @username, отправить документ. Но обычный Telegram-бот не может первым начать личный разговор с произвольным человеком. Передача имени пользователя в sendDocument сама по себе эту задачу не решает. У Telegram это ограничение описано в введении в платформу ботов.
Я предусмотрел два способа доставки. Первый — отправка через личную Telegram-сессию с помощью Telethon. Второй — приглашение по одноразовой ссылке, после которого человек сам открывает бота.
Создание и отправка доступны только моим управляющим аккаунтам, которые бот определяет по числовым ID. В проверках ниже «владелец» — это мой аккаунт управления. Для прямой отправки нужны следующие условия:
-
Команда пришла от разрешённого владельца в личном чате.
-
Владелец подтвердил конкретный профиль и конкретного получателя.
-
Сессия Telegram принадлежит ожидаемому аккаунту.
-
Получатель — обычный пользователь, а не бот, канал или группа.
-
Файл — обычный закрытый
.ovpn, расположенный в разрешённом каталоге.
Лимит в пять отправок за час сохраняется в SQLite и переживает перезапуск процесса. Ограничение Telegram FloodWait тоже обрабатывается отдельно: ошибка не должна превращаться в бесконечные повторные отправки.
В функции прямой отправки отключены получение обновлений и сохранение сущностей; она не читает историю, чаты или контакты. Это ограничение кода: сам файл авторизованной MTProto-сессии остаётся доступом к аккаунту. Специального разрешения «только отправлять VPN» у него нет. Документация Telethon отдельно объясняет, почему нужно беречь сессии.
Как я устроил одноразовую выдачу
В ранней запасной схеме файл мог получить тот, кто первым открыл ссылку. После пересылки приглашения это был бы не обязательно нужный человек. Поэтому выдачу я связал с ожидаемым ником и добавил подтверждение фактического числового Telegram ID владельцем.
Последовательность стала такой:
Владелец выбирает профиль и ожидаемый @username -> бот создаёт случайное приглашение на 24 часа -> человек открывает /start по приглашению -> бот показывает владельцу ник и фактический Telegram ID -> владелец подтверждает получателя в течение 5 минут -> приглашение атомарно погашается -> файл отправляется в подтверждённый личный chat_id
В базе хранится SHA-256 случайного токена. Токен генерируется из 24 случайных байт; исходная ссылка не попадает в журнал событий. Проверка и погашение выполняются внутри транзакции BEGIN IMMEDIATE, чтобы два параллельных запроса не получили две выдачи.
Сокращённый пример с обеими операциями:
import hashlibimport secretsimport timedef make_grant(con, name, expected_username): token = secrets.token_urlsafe(24) digest = hashlib.sha256(token.encode()).hexdigest() with con: con.execute( "INSERT INTO vpn_grants(hash,name,expires,used,recipient) " "VALUES(?,?,?,0,?)", (digest, name, time.time() + 86400, expected_username.lower()), ) return tokendef consume_grant(con, presented_token, expected_username): digest = hashlib.sha256(presented_token.encode()).hexdigest() con.execute("BEGIN IMMEDIATE") try: row = con.execute( "SELECT name FROM vpn_grants " "WHERE hash=? AND used=0 AND expires>? AND recipient=?", (digest, time.time(), expected_username.lower()), ).fetchone() if row: con.execute("UPDATE vpn_grants SET used=1 WHERE hash=?", (digest,)) con.commit() return row except Exception: con.rollback() raise
con здесь — соединение с уже созданной таблицей vpn_grants. Для погашения используется хеш предъявленного пользователем токена; в полном коде отдельно проверяются формат, отзыв профиля, подтверждение владельца и фактический получатель. Фрагмент показывает транзакцию, а не весь обработчик /start.
Погасить ссылку и отправить документ через внешний API нельзя одной транзакцией SQLite. Если Telegram не подтвердил отправку после погашения, бот сообщает о неудаче и просит создать новую ссылку. Я предпочёл повторить понятную операцию, а не автоматически выдавать секрет ещё раз при неизвестном результате.
Как я ограничил команды бота
Имя профиля, ник и подтверждения обрабатываются отдельными функциями. Для создания и отзыва существует фиксированный путь исполнения. Произвольное сообщение в чате не превращается в shell-команду администратора.
Проверка старого списка диагностических команд выявила обход: код проверял только начало строки, а за допустимым словом могло идти продолжение. Исправление — полное сопоставление разрешённой операции и передача аргументов отдельным списком без shell=True.
Принцип хорошо виден на маленьком примере:
import subprocessDIAGNOSTICS = { "uptime": ["uptime"], "vpn-state": [ "systemctl", "show", "openvpn-server@lab.service", "--property=ActiveState,SubState,NRestarts,ExecMainStatus", ],}def run_diagnostic(name): if name not in DIAGNOSTICS: raise ValueError("Неизвестная операция") return subprocess.run( DIAGNOSTICS[name], shell=False, check=True, capture_output=True, text=True, timeout=10, ).stdout
Состав разрешённых операций всё равно нужно выбирать под конкретный сервер. Даже команда чтения может раскрывать лишнее, если ей разрешить произвольный путь или полный вывод окружения.
Статистика подключений и собственная ошибка наблюдателя
У бота появились три разных отчёта:
|
Команда |
Назначение |
|---|---|
|
|
Кто подключён сейчас, адреса туннеля и счётчики |
|
|
История по профилям и дням за выбранный период |
|
|
Агрегаты DNS-запросов по доменам |
Сессии накапливались в SQLite, чтобы история не зависела только от длины системного журнала. Уникальная пара «время — профиль» защищала от повторной вставки одного прочитанного события.
Во время ревью обнаружилось, что парсер принимал любое VERIFY OK: depth=N, CN=... за клиентскую сессию. OpenVPN пишет такие строки для разных уровней цепочки сертификатов.
На синтетическом примере проблема выглядит так:
VERIFY OK: depth=1, CN=lab-caVERIFY OK: depth=0, CN=lab-phone
Старый парсер добавлял и lab-ca, и lab-phone. В первом случае это центр сертификатов, а не второй клиент. Более того, успешная проверка сертификата сама по себе не означает, что подключение полностью завершилось.
В исходном коде OpenVPN имя удалённой стороны берётся с уровня cert_depth == 0; это можно проверить в реализации проверки сертификатов. Для учёта рабочих сессий дополнительно нужны события установления и завершения соединения либо данные интерфейса управления.
В старом обработчике vpn_sessions_watch() ещё осталась проверка depth=\d+ и чтение хвоста журнала одного экземпляра. Поэтому для точного числа клиентов нужен корректный источник событий: установленное соединение и его завершение. Доработка этого агрегатора осталась в плане обслуживания.
Этап 3. Один профиль и шторм переподключений
В отдельном инциденте iPhone переподключался примерно каждые шестнадцать секунд. В журнале были сообщения о том, что новое соединение с тем же CN вытесняет предыдущее. При выключенном duplicate-cn сервер не должен одновременно обслуживать несколько клиентов под одной такой идентичностью.
Это согласуется с использованием одного профиля несколькими подключениями или с повторными попытками самого клиента. По одной строке нельзя определить, кто именно запускал каждую попытку.
Я выбрал отдельный профиль на устройство. Включение duplicate-cn сохранило бы общую идентичность, тогда как мне нужен независимый отзыв доступа.
Тогда же выявился пробел наблюдения: накопление истории не предупредило о шторме. Для сторожа я сформулировал критерий — не менее двадцати повторных подключений одного профиля за десять минут, с подавлением одинаковых предупреждений на шесть часов. Автоматический алерт оставил отдельной задачей развития.
Для первичного разбора полезнее начать с ограниченного временного окна:
sudo journalctl -u openvpn-server@lab.service \ --since '15 minutes ago' \ --until 'now' \ --output=short-iso-precise --no-pager
Журнал может содержать клиентские адреса и имена сертификатов. В публикацию достаточно вынести обезличенный фрагмент, описывающий механизм сбоя. А для постоянного наблюдения нужен курсор журнала: чтение последних двухсот строк не гарантирует, что между опросами не потерялись события.
Этап 4. Пересборка OpenVPN и одиннадцать новых профилей
К этому моменту часть прежних сертификатов уже была отозвана. Исправить такой профиль сменой адреса сервера нельзя: сервер перестал доверять именно его сертификату.
Я пересобрал комплект: новый центр сертификатов, серверный сертификат и одиннадцать отдельных клиентских профилей. Подготовил UDP- и TCP-варианты подключения, а Windows оставил дополнительным TCP-входом к отдельному экземпляру на NL.
Ошибка с именем сертификата
В промежуточном комплекте имя центра сертификатов случайно оказалось в сертификатах сервера и клиентов. Файлы существовали, команды выпуска завершались, но идентичности были неправильными. Я исправил ошибку до выдачи комплекта.
Проверять нужно не только подпись, но и subject, назначение сертификата и совпадение ключа. Команды ниже выводят метаданные и хеш публичной части; приватный ключ в терминал не печатается:
openssl x509 -in server.crt -noout \ -subject -issuer -serial -dates -ext extendedKeyUsageopenssl x509 -in lab-phone.crt -noout \ -subject -issuer -serial -dates -ext extendedKeyUsageopenssl verify -CAfile ca.crt -purpose sslserver server.crtopenssl verify -CAfile ca.crt -purpose sslclient lab-phone.crtopenssl pkey -in lab-phone.key -pubout -outform DER | openssl sha256openssl x509 -in lab-phone.crt -pubkey -noout \ | openssl pkey -pubin -outform DER | openssl sha256
Последние два хеша должны совпасть: сертификат относится к тому ключу, который окажется в профиле.
В старой NL-ветке генератора проверяются имя запроса и наличие материалов, но нет явного контроля CN, issuer, назначения сертификата и совпадения ключа после Easy-RSA. Эти проверки выполнялись вручную; их автоматизацию я оставил в плане обслуживания. Windows-ветка имеет собственные проверки CA и CRL.
Отдельно проверяется список отзыва — CRL. Без него кнопка «отозвать» легко превратится в изменение записи в меню, которое ничего не меняет для сервера.
openssl crl -in crl.pem -noout -issuer -lastupdate -nextupdateopenssl crl -in crl.pem -noout -verify -CAfile ca.crtopenssl verify -CAfile ca.crt -CRLfile crl.pem \ -crl_check -purpose sslclient lab-phone.crt
Как выглядит выпуск в отдельной учебной PKI
Ниже пример с Easy-RSA 3, который создаёт новую учебную PKI. Его следует сохранить как отдельный скрипт, например init-lab-pki.sh, и запустить через bash init-lab-pki.sh: защитный exit 1 остановит скрипт, не закрывая интерактивную SSH-сессию. Запуск поверх действующего центра сертификатов не предусмотрен. Все относительные пути дальше относятся к создаваемому каталогу.
# Ubuntu 24.04; отдельный новый стенд.sudo apt-get updatesudo apt-get install openvpn easy-rsa opensslumask 077LAB_PKI_DIR="$HOME/vpn-lab-pki"if [ -e "$LAB_PKI_DIR" ]; then printf '%s\n' 'Каталог уже существует; сначала проверьте его содержимое' >&2 exit 1fimake-cadir "$LAB_PKI_DIR"cd "$LAB_PKI_DIR"./easyrsa init-pki./easyrsa --req-cn='Lab VPN CA' build-ca./easyrsa --batch build-server-full lab-server nopass./easyrsa --batch build-client-full lab-phone nopass./easyrsa --batch gen-crlopenvpn --genkey secret tls-crypt.key
build-ca здесь запрашивает пароль центра сертификатов. Серверный и клиентский ключи созданы без пароля для демонстрации автоматического запуска и импорта; доступ к их файлам должен быть закрыт. Центр сертификатов не обязан находиться на рабочем VPN-сервере. Описание команд и формата PKI есть в документации Easy-RSA.
Проверка полученного комплекта:
openssl verify -CAfile pki/ca.crt -purpose sslserver pki/issued/lab-server.crtopenssl verify -CAfile pki/ca.crt -purpose sslclient pki/issued/lab-phone.crtopenssl crl -in pki/crl.pem -noout -nextupdate
Замена CA потребовала заменить и доверенный сертификат в клиентских файлах. Старый .ovpn нельзя было сделать новым простым переименованием или повторной отправкой.
Клиент подключается, а интернет не работает
После пересборки OpenVPN проходил TLS, а клиент показывал Connected. Но сайты не открывались: DNS и HTTPS зависали. Проблема была уже не в выдаче сертификатов, а в передаче пользовательских данных.
Я разбил диагностику на участки, чтобы не менять DNS, MTU, шифр, порт и сертификаты одновременно.
|
Участок |
Что проверяли |
Какой вопрос закрывает проверка |
|---|---|---|
|
Внешний TCP-вход |
Доступность нужного адреса и порта |
Доходит ли соединение до точки входа |
|
OpenVPN TLS |
Сертификаты, имя сервера, завершение согласования |
Узнали ли стороны друг друга |
|
Интерфейс туннеля |
Выданный адрес, маршруты |
Подготовлена ли передача пользовательского трафика |
|
Выход сервера |
Forwarding, NAT, обратный путь |
Может ли пакет покинуть сервер и вернуться |
|
DNS |
Запрос к ожидаемому резолверу |
Работает ли разрешение имён |
|
HTTPS |
Запрос по имени и загрузка |
Проходит ли прикладной трафик |
|
Устройство |
Внешний IP, сайты, смена сети |
Получил ли пользователь ожидаемый результат |
Что показали разные маршруты
Прямое подключение с ноутбука воспроизводило ситуацию «TLS есть, данные не проходят». Тот же сервер через другой рабочий VPN ноутбука передавал данные. Изолированный полноценный клиент через физический Wi-Fi тоже не смог передавать DNS и HTTPS.
Это помогло сузить поиск до различий сетевого пути. Но не доказывало, что виноват конкретный оператор, определённая модель фильтра или OpenVPN вообще.
На Windows трассировка показывала короткий TCP-ответ: он повторялся с тем же порядковым номером без подтверждения клиента. Локальный счётчик потерь PktMon был нулевым. Это не исключало потерь на других участках пути. Замена внешних портов и переход с tls-crypt на tls-auth на телефоне проблему не устранили.
Значение имел и неудачный контроль: маленький повторяющийся ответ не поддерживал объяснение «теряются только большие пакеты из-за MTU». Поэтому универсальное снижение MTU не было доказанным исправлением именно этого случая.
Для такой диагностики сначала собирается небольшой снимок состояния:
# NL: процессы, сокеты, интерфейсы и маршруты.systemctl show openvpn-server@lab.service \ --property=ActiveState,SubState,NRestarts,ExecMainStatussudo ss -lntupip -brief addressip route showsysctl net.ipv4.ip_forward
А на Windows — состояние нужного сервиса, соединений и NAT:
Get-Service OpenVPNServiceGet-NetTCPConnection -State Listen | Select-Object LocalAddress,LocalPort,OwningProcessGet-NetNatGet-NetIPInterface -AddressFamily IPv4 | Select-Object InterfaceAlias,InterfaceIndex,Forwarding,ConnectionStateGet-NetRoute -DestinationPrefix '0.0.0.0/0' | Select-Object InterfaceIndex,NextHop,RouteMetric
В диагностике нужно смотреть именно тот адрес, который указан в профиле. В одном из опытов основной адрес Windows не принимал нужное соединение, а дополнительный адрес из профиля принимал.
Зачем понадобился собственный TCP-посредник
На отдельном входе помогла раздельная отправка первого байта очередной порции TCP и оставшихся байтов с паузой двадцать миллисекунд. Вместе с изменением OpenVPN keepalive на отдельном backend это восстановило проверенный путь передачи.
Здесь важна точность описания. Посредник не меняет шифрование и не разбирает содержимое VPN. Он меняет порядок вызовов отправки и временные интервалы между ними. Это также не директива OpenVPN fragment.
TCP остаётся потоком байтов. Два вызова sendall() не гарантируют, что на любом участке сети возникнут ровно два IP-пакета. Изменение поведения отправителя помогло на проверенных путях; универсальность такого способа обхода фильтрации я не проверял.
Начальная обработка передаваемых данных выглядит так:
destination.sendall(data[:1])if len(data) > 1: if stop_event.wait(0.02): return False destination.sendall(data[1:])
У рабочего посредника были ограничения числа соединений, ожидания первого ответа, времени простоя и отправки. Его нельзя было использовать как произвольный HTTP- или SOCKS-прокси: адрес backend задавался оператором и ограничивался моей схемой.
Журнал содержал тип ошибки и счётчики байтов. Записывать туда содержимое пакетов или ключи для выполнения этой задачи не требовалось.
Почему я изменил keepalive только у отдельного экземпляра
Сначала помогло разделение отправки, но затем соединение снова пропадало при работе OpenVPN keepalive. На отдельном совместимом backend отключили протокольные ping и ping-restart; посредник использовал keepalive самого TCP-сокета.
Настройки этого совместимого экземпляра:
# Фрагмент отдельного OpenVPN backend, не общая рекомендация для всех VPN.ping 0ping-restart 0push "ping 0"push "ping-restart 0"
Это убирает обычный механизм обнаружения отсутствующего peer на уровне OpenVPN. Копировать изменение без наблюдения за соединением и процедуры восстановления не стоит. Я применял его к выделенному экземпляру, сохраняя остальные входы.
TCP-сокеты посредника настраивались независимо:
sock.settimeout(5)sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)if os.name == "nt": sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 60000, 10000))
Последняя строка задаёт Windows-параметры TCP keepalive в миллисекундах. Они не превращаются в настройки OpenVPN на телефоне.
Дополнительный IP и правильный исходящий адрес
На NL было два IPv4. В UDP-конфигурации потребовался multihome: ответ должен возвращаться с того адреса, к которому обращался клиент. Наличие двух IP затронуло и выходной NAT.
Для совместимого OpenVPN-экземпляра я явно сохранял ожидаемый исходящий адрес. Иначе вход мог работать через дополнительный IP, а проверка внешнего адреса показывала бы другой выход той же машины.
Иллюстрация правила SNAT для отдельного стенда:
# Только пример для заранее подготовленной цепочки iptables на отдельном стенде.# ens3 и адрес заменяются по фактической схеме машины.sudo iptables -t nat -C POSTROUTING \ -s 10.77.0.0/24 -o ens3 -j SNAT --to-source 198.51.100.10 \ || sudo iptables -t nat -A POSTROUTING \ -s 10.77.0.0/24 -o ens3 -j SNAT --to-source 198.51.100.10
Одного SNAT недостаточно для готового шлюза. Нужны включённая маршрутизация, разрешённый транзит в действующем брандмауэре, обратный трафик и правила изоляции. На сервере с UFW или nftables сначала нужно разобраться с действующими цепочками; приведённое правило не заменяет их конфигурацию.
В моей установке NAT обеспечивался перед запуском отдельного экземпляра. Повторная операция проверяла существование правила, а прежние правила не удалялись. Два IP при этом оставались адресами одного сервера с одним набором общих зависимостей.
IPv6 пришлось учитывать отдельно
IPv4 может исправно идти через VPN, а IPv6 — по обычной сети устройства. В этом случае проверка одного внешнего IPv4 не покажет обход туннеля.
Для совместимого NL-маршрута я выбрал блокировку IPv6 внутри туннеля: предоставил IPv6-адреса туннеля, направил туда IPv6 и включил block-ipv6. Значимые директивы:
# Учебный адресный префикс внутри VPN.server-ipv6 fd42:77:77::/64block-ipv6push "block-ipv6"push "redirect-gateway def1 bypass-dhcp ipv6"
Это выбранная политика для IPv4-выхода, а не реализация полноценного IPv6-доступа. Такой способ описан в руководстве OpenVPN 2.6. Для другого экземпляра или клиента проверка повторяется отдельно: наличие этих строк у одного backend не доказывает защиту всех профилей.
Изолированный клиентский тест показал, что IPv6-маршрут направляется в туннель и соединение IPv6 блокируется, а IPv4 продолжает работать. При переносе настроек на другой Windows-профиль эта политика проверяется отдельно.
Автозапуск оказался отдельной задачей
Для постоянной работы я зарегистрировал Python-посредник в Windows Task Scheduler — планировщике задач. Встроенная настройка перезапуска при ошибке не восстановила принудительно остановленный процесс в этом опыте.
Я добавил отдельный минутный сторож. Он проверял только свою задачу, не останавливал посторонние процессы и ограничивал восстановительные попытки. Если дополнительный IP ещё не готов, ожидание не должно расходовать единственную попытку восстановления на час.
Проверка после установки включала остановку собственного процесса, ожидание нового PID и проверку слушателя. Один статус Ready у задачи этого не доказывает: он может означать лишь ожидание очередного запуска.
Для чтения состояния:
Get-ScheduledTask -TaskName 'BUEOpenVPNEntry','BUEOpenVPNEntryWatch' | Select-Object TaskName,StateGet-ScheduledTaskInfo -TaskName 'BUEOpenVPNEntryWatch' | Select-Object LastRunTime,LastTaskResult,NextRunTime
Полную перезагрузку серверов в той приёмке я не проводил. Автозапуск был настроен, восстановление после остановки процесса проверено; запуск после reboot требует отдельного испытания.
Этап 5. Ускорение, удачный замер и откат
Постоянная пауза при передаче ограничивает скорость. Появилась ускоренная версия посредника: после начального участка она переставала делить данные и ждать.
В одном тесте на iPhone через LTE результат составил 10 Мбит/с на скачивание, 18 Мбит/с на отправку и ping 126 мс. При первом подключении интернет не появился; успешной оказалась повторная попытка. Несколько подключений подряд и пять минут устойчивой передачи ещё предстояло проверить.
Вскоре VPN снова перестал передавать данные, хотя процесс посредника и серверный OpenVPN оставались запущенными. Ускоренную версию пришлось откатить.
Я вернул прежний файл посредника, проверил его SHA-256 и перезапустил только соответствующую задачу. Ускоренный исходник сохранил отдельно. Конфигурацию, клиентские ключи, остальные VPN и основной маршрут не менял.
После отката я проверил тот же профиль на LTE: интернет снова работал. Это поддерживает гипотезу регрессии ускоренного режима. Точный механизм сетевого отказа такое сравнение не устанавливает.
Для подобных изменений полезен обычный снимок файла до установки новой версии:
$entryPath = 'C:\vpn-lab\vpn_tcp_entry.py'$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')$savedPath = "$entryPath.before-$stamp"Copy-Item -LiteralPath $entryPath -Destination $savedPath -ErrorAction StopGet-FileHash -LiteralPath $entryPath -Algorithm SHA256Get-FileHash -LiteralPath $savedPath -Algorithm SHA256
Откат требует ещё выбранного проверенного файла, привязки задачи к нему и подтверждения, что восстанавливается именно нужный компонент. Массовый taskkill всех Python-процессов для этого не нужен.
После этого я перестал принимать скорость за единственный критерий. Следующая версия должна была пройти одинаковые повторные подключения, передачу данных и проверку на том же устройстве, а затем уже сравниваться по скорости.
Этап 6. Собственный OpenVPN на Windows
Следующая задача — сделать выход через Windows самостоятельным, без пересылки пользовательского трафика на NL.
Просто перенести внешний порт было недостаточно. На Windows понадобились собственный OpenVPN, виртуальный сетевой адаптер, PKI, подсеть клиентов и NAT.
До пилота своего OpenVPN и WinNAT для него на этой машине не было. Я выбрал официальный подписанный MSI OpenVPN Community и отдельный TAP-адаптер. В установке использовался OpenVPN 2.7.8. Директива windows-driver удалена в OpenVPN 2.7, поэтому я не переносил её в конфигурацию; использовал disable-dco и выбранный TAP.
Официальные установщики для Windows включают и клиентский интерфейс, и службу сервера; это описано на странице OpenVPN Community. Работа службы и обработка файлов конфигурации разобраны в документации Windows service.
Сначала проверка установленных программ и сети
Проверка подписи и версии из PowerShell администратора:
$vpnExe = 'C:\Program Files\OpenVPN\bin\openvpn.exe'$sslExe = 'C:\Program Files\OpenVPN\bin\openssl.exe'foreach ($binary in @($vpnExe, $sslExe)) { if ((Get-AuthenticodeSignature -LiteralPath $binary).Status -ne 'Valid') { throw "Недействительная подпись: $binary" }}& $vpnExe --versionGet-NetAdapter | Select-Object Name,InterfaceDescription,Status,ifIndexGet-NetIPAddress -AddressFamily IPv4 | Select-Object InterfaceAlias,IPAddress,PrefixLengthGet-NetNatGet-NetRoute -DestinationPrefix '0.0.0.0/0'
Скрипт установки пилота останавливался при занятом порте, конфликтующей подсети, постороннем NAT, неожиданном работающем OpenVPN или несовпадении контрольной суммы прежнего посредника. Он не должен был «починить» новый сервер ценой разрушения старого входа.
Закрытый каталог и права
Windows использует NTFS-права, а не Linux-режимы 600 и 700. Для учебного каталога смысл начальной настройки можно показать так:
$vpnRoot = 'C:\vpn-lab\windows-openvpn'if (Test-Path -LiteralPath $vpnRoot) { throw 'Каталог уже существует; требуется проверка перед продолжением'}New-Item -ItemType Directory -Path $vpnRoot -ErrorAction Stop | Out-Null# SID не зависят от языка Windows: SYSTEM и локальные Administrators.icacls.exe $vpnRoot /inheritance:rif ($LASTEXITCODE -ne 0) { throw 'Не удалось отключить наследование' }icacls.exe $vpnRoot /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F'if ($LASTEXITCODE -ne 0) { throw 'Не удалось назначить закрытые права' }foreach ($name in @('pki','clients','logs','snapshots','setup')) { New-Item -ItemType Directory -Path (Join-Path $vpnRoot $name) | Out-Null}
После создания ещё проверяется итоговый ACL — список разрешений. Неожиданный разрешённый пользователь в защищённом каталоге должен остановить выпуск.
Службе OpenVPN нужны серверный ключ, серверный сертификат, CA-сертификат, CRL и tls-crypt, но не приватный ключ центра сертификатов и не ключи клиентов. У меня служба работала от NT SERVICE\OpenVPNService. Ей были выданы чтение нужных серверных файлов и запись в каталог журналов. Рекурсивное разрешение чтения всей PKI раскрыло бы лишние ключи.
Что выпускала отдельная Windows PKI
Для пилота были созданы собственный CA и ровно один тестовый клиентский сертификат. Назначения разделены расширениями X.509:
[ ca_extensions ]basicConstraints = critical,CA:TRUE,pathlen:0keyUsage = critical,keyCertSign,cRLSignsubjectKeyIdentifier = hashauthorityKeyIdentifier = keyid:always[ server_extensions ]basicConstraints = critical,CA:FALSEkeyUsage = critical,digitalSignature,keyEnciphermentextendedKeyUsage = serverAuthsubjectKeyIdentifier = hashauthorityKeyIdentifier = keyid,issuer[ client_extensions ]basicConstraints = critical,CA:FALSEkeyUsage = critical,digitalSignatureextendedKeyUsage = clientAuthsubjectKeyIdentifier = hashauthorityKeyIdentifier = keyid,issuer
CA действовал 1095 дней, серверный и клиентский сертификаты — 365 дней, CRL — 30 дней. Эти сроки относятся к тому пилотному выпуску, а не ко всем нынешним профилям. Обновление CRL нужно рассматривать отдельно от срока клиентского сертификата: просроченный список отзыва может остановить новые подключения задолго до истечения самого сертификата.
В этом Windows-пилоте ключ CA создавался без пароля на том же узле. Доступ ограничивался NTFS-правами; службе OpenVPN чтение ключа CA не выдавалось. Для постоянного развёртывания хранение и резервное восстановление ключа CA нужно спроектировать отдельно. В описываемой установке CA оставалась на Windows.
Команды OpenSSL для этого выпуска, если openssl.cnf уже содержит базу CA, политику и секции выше:
# Выполняется в новом закрытом pki, а не в каталоге действующей CA.$ssl = 'C:\Program Files\OpenVPN\bin\openssl.exe'& $ssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out ca.key& $ssl req -new -x509 -key ca.key -sha256 -days 1095 ` -config openssl.cnf -extensions ca_extensions -out ca.crt& $ssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out server.key& $ssl req -new -key server.key -subj '/CN=lab-windows-server' ` -config openssl.cnf -out server.csr& $ssl ca -batch -notext -config openssl.cnf ` -extensions server_extensions -in server.csr -out server.crt& $ssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out lab-phone.key& $ssl req -new -key lab-phone.key -subj '/CN=lab-phone' ` -config openssl.cnf -out lab-phone.csr& $ssl ca -batch -notext -config openssl.cnf ` -extensions client_extensions -in lab-phone.csr -out lab-phone.crt& $ssl ca -batch -config openssl.cnf -gencrl -out crl.pem& $ssl verify -CAfile ca.crt -CRLfile crl.pem -crl_check ` -purpose sslserver server.crt& $ssl verify -CAfile ca.crt -CRLfile crl.pem -crl_check ` -purpose sslclient lab-phone.crt
Мой полный скрипт проверял код возврата после каждого внешнего вызова, не перезаписывал существующие файлы и складывал служебный вывод в закрытый журнал. Перечень команд выше объясняет выпуск; запуск их поверх чужого index.txt не является процедурой восстановления.
Полный PKI-сценарий с конфигурацией CA, проверкой результатов команд и запретом перезаписи приведён в приложении к статье. Ключ tls-crypt создаётся отдельно:
& 'C:\Program Files\OpenVPN\bin\openvpn.exe' ` --genkey secret 'C:\vpn-lab\windows-openvpn\pki\tls-crypt.key'
OpenVPN, адаптер и WinNAT
Конечный вариант после добавления локального обработчика слушает OpenVPN только на loopback. Обезличенный фрагмент server.ovpn:
local 127.0.0.1port 15444proto tcp-serverdev tundev-node Lab-OpenVPNdisable-dcotopology subnetserver 10.78.0.0 255.255.255.0dh noneca C:/vpn-lab/windows-openvpn/pki/ca.crtcert C:/vpn-lab/windows-openvpn/pki/server.crtkey C:/vpn-lab/windows-openvpn/pki/server.keycrl-verify C:/vpn-lab/windows-openvpn/pki/crl.pemtls-crypt C:/vpn-lab/windows-openvpn/pki/tls-crypt.keyverify-client-cert requireremote-cert-tls clienttls-version-min 1.2data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305auth SHA256allow-compression nopush "redirect-gateway def1 bypass-dhcp"push "dhcp-option DNS 1.1.1.1"tcp-nodelaypersist-keypersist-tunmax-clients 2tun-mtu 1400ip-win32 netshstatus C:/vpn-lab/windows-openvpn/logs/server-status.log 10status-version 3log-append C:/vpn-lab/windows-openvpn/logs/server.logverb 3
Здесь max-clients 2 — ограничение пилота; семейный сервер впоследствии расширялся. tun-mtu 1400 — выбранное значение пилота, а не доказанная причина прежнего зависания. При согласованном GCM строка auth SHA256 не добавляет отдельный HMAC каналу данных: аутентификацию выполняет сам AEAD-шифр.
Профиль клиента в этой схеме указывает на внешний Windows-вход 203.0.113.20:5444, а не на loopback. В нём сохраняются remote-cert-tls server, проверка ожидаемого имени сертификата, современные шифры и запрет сжатия. Блоки CA, сертификата клиента, клиентского ключа и tls-crypt добавляет генератор; настоящих значений в статье нет.
После появления отдельного TAP и проверки конфликта подсетей разрешается IPv4 forwarding на нужных интерфейсах и создаётся WinNAT. В учебном варианте:
# Имена и индексы сначала сверяются с Get-NetAdapter / Get-NetIPAddress.$tapIndex = (Get-NetAdapter -Name 'Lab-OpenVPN' -ErrorAction Stop).ifIndex$wanIndex = (Get-NetIPAddress -IPAddress '203.0.113.20' -ErrorAction Stop).InterfaceIndexif (Get-NetNat -Name 'Lab-OpenVPN-NAT' -ErrorAction SilentlyContinue) { throw 'NAT с таким именем уже существует; нужна сверка'}if (@(Get-NetNat).Count -ne 0) { throw 'На машине есть другой NAT; автоматическая установка остановлена'}Set-NetIPInterface -InterfaceIndex $tapIndex -AddressFamily IPv4 -Forwarding EnabledSet-NetIPInterface -InterfaceIndex $wanIndex -AddressFamily IPv4 -Forwarding EnabledNew-NetNat -Name 'Lab-OpenVPN-NAT' -InternalIPInterfaceAddressPrefix '10.78.0.0/24'
Смысл New-NetNat и параметра внутренней подсети описан в Microsoft Learn. Команда создаёт трансляцию адресов, но не выпускает сертификаты, не настраивает клиентские маршруты и не задаёт политику брандмауэра.
Основной маршрут Windows до и после операции я сравнивал отдельно. Направить весь сервер через его собственный VPN было бы особенно неприятно на машине с другими задачами.
Почему на Windows снова появился обработчик TCP
Новый независимый сервер сначала тоже проходил TLS и назначал виртуальный адрес, но затем передача зависала. На внешнем TCP повторялся небольшой ответ без ACK. WinNAT при этом создавал исходящие сеансы.
Я поставил локальный обработчик между внешним портом Windows и OpenVPN на 127.0.0.1. Backend в его коде жёстко привязан к loopback. Весь VPN-сервер и выход остались на Windows, без скрытого участка до NL.
После переключения пилотная сессия передала около 52,8 МБ к серверу и 2,2 МБ обратно; появились внешние NAT-сеансы с Windows-адресом. Это подтвердило, что передача восстановилась. Причины последующих коротких переподключений одним этим наблюдением ещё не определялись.
Как я ускорил локальный обработчик и что измерял
После неудачи с ранним переходом в быстрый режим я сохранил начальную обработку Windows-варианта до выполнения двух условий:
-
прошло не менее десяти секунд с начала соединения;
-
в данном направлении уже передано не менее 16 КиБ.
Порог проверяется отдельно для каждого направления. Быстрый исходящий поток не должен снимать начальный режим у обратного потока, который ещё почти ничего не передал.
В local_tcp_entry.py эти условия выглядят так:
def forward(self, data, destination, transferred, elapsed): if elapsed >= self.warmup_seconds and transferred >= self.warmup_bytes: destination.sendall(data) return True destination.sendall(data[:1]) if len(data) > 1: if self.stop.wait(self.delay): return False destination.sendall(data[1:]) return True
В цикле передачи transferred выбирается из независимых счётчиков:
transferred = sent if origin is client else receiveddestination = backend if origin is client else clientif not self.forward(data, destination, transferred, time.monotonic() - started): returnif origin is client: sent += len(data)else: received += len(data)
Пороговое значение — эвристика посредника, принятая после ограниченных проверок. Оно не относится к шифрованию или свойствам OpenVPN. На другом сетевом пути его полезность придётся проверять заново.
Сокеты используют TCP_NODELAY; на OpenVPN я включил tcp-nodelay. Одновременно обработчик допускает восемь соединений, ограничивает ожидание первого ответа и полный простой. Выход освобождает занятый слот и закрывает сокеты в finally.
Запуск на учебном Windows-узле с уже подготовленным локальным OpenVPN выглядит так:
& 'C:\Program Files\Python312\python.exe' ` 'C:\vpn-lab\windows-openvpn\setup\local_tcp_entry.py' ` --listen-ip '203.0.113.20' --listen-port 5444 --backend-port 15444 ` --log 'C:\vpn-lab\windows-openvpn\logs\entry.log'
В рабочей установке запуск оформлялся отдельной задачей планировщика, а не зависел от открытого PowerShell. Разрешение внешнего входа ограничивалось нужной программой, локальным адресом и портом; сам OpenVPN оставался недоступен снаружи на loopback.
Полный обработчик local_tcp_entry.py и три теста test_local_tcp_entry.py приведены в приложении к статье. Их нужно сохранить рядом; запуск из этого каталога — python -m unittest -v test_local_tcp_entry.py. Тесты проверяют сохранение двоичных данных через настоящий loopback и границы быстрого режима. К рабочему VPN они не подключаются.
Три разных измерения скорости
Вот результаты, которые легко по ошибке смешать:
|
Проверка |
Наблюдение |
Что можно из этого заключить |
|---|---|---|
|
Локальный обмен на Windows: по 8 МиБ в обе стороны, старые постоянные паузы |
8,059 с, около 16,65 Мбит/с суммарно |
В обработчике было программное ограничение |
|
Такой же локальный обмен после перехода на быстрый этап |
0,111 с, около 1204,41 Мбит/с суммарно |
Локальное ограничение существенно уменьшилось; целостность данных сохранилась |
|
Ping с Windows до iPhone внутри туннеля |
8 ответов из 8, среднее 75,5 мс |
Работала двусторонняя передача до конкретного телефона в этом окне |
|
Мой последующий замер на телефоне |
Около 10 Мбит/с вниз, 20 Мбит/с вверх, ping 80 мс, jitter 9 мс |
Windows-профиль давал реальный интернет с этими показателями в том тесте |
1204 Мбит/с здесь — loopback, а не интернет через LTE. Средние 75,5 мс внутри туннеля тоже нельзя выдавать за ping до сервера Speedtest.
Хотелось получить не менее 20 Мбит/с на скачивание. Телефонный результат в 10 Мбит/с этой цели не достиг, но реальное подключение уже работало.
Как я разбирался с фоновым режимом iPhone
После удачного теста я заметил, что VPN как будто отключается при уходе телефона в фон. В отдельном опыте речь шла об автоматическом переподключении примерно на две–пять секунд после разблокировки — и на Wi-Fi, и на LTE. Причину этого короткого переподключения тот опыт не установил; один Speedtest её тоже не объясняет.
К этому моменту Battery Saver в OpenVPN Connect уже был выключен. Поэтому повторить совет «выключите экономию батареи» было бы недостаточно.
Настройки приложения всё же нужно различать. По документации OpenVPN Connect для iOS, Battery Saver влияет на переподключение при заблокированном экране, а Seamless Tunnel блокирует интернет во время паузы или переподключения. Seamless Tunnel не обещает физически непрерывное соединение.
Для такого поведения нужен отдельный опыт: включить профиль, зафиксировать время, заблокировать экран на несколько минут, разблокировать и сопоставить журнал приложения с сервером. Затем повторить переход Wi-Fi → LTE и обратно. Я сохранил эту последовательность в приёмке будущих изменений. Она позволяет проверить повседневное использование телефона, а не только Speedtest с включённым экраном.
Новые семейные профили работали, а меню выдавало старые
После пилота я подготовил для Windows отдельные семейные сертификаты и профили с привычными человеку названиями. Старый центр сертификатов NL сохранил.
При проверке новый Windows-файл работал, а профили из /vpn — нет. Оказалось, новые файлы были отправлены отдельными документами, а команда всё ещё открывала прежний каталог NL.
Я изменил главное меню: первыми стали новые отдельные Windows-профили. Для NL сделал явно подписанный старый раздел, AmneziaWG вынес отдельной кнопкой. Старые кнопки без нового пространства имён больше не должны молча выдавать другой набор файлов.
После обновления Windows-раздел появился и нужный файл стал приходить через главное меню. Затем я открыл управление двум своим заранее разрешённым Telegram-аккаунтам: каждый получает функции только в своём личном чате. Числовой ID проверяется отдельно от отображаемого ника.
У бота получилась машина времени: нажимаешь /vpn после обновления сервера — и получаешь файл из прошлой версии. Аттракцион закрыли обновлением меню.
После этого в приёмку вошёл весь путь: от команды в главном меню до подключения на устройстве. Исправный сервер бесполезен пользователю, если бот выдаёт файл от другой схемы.
Как теперь создаётся Windows-профиль
Установленный код делает несколько последовательных проверок:
-
Проверяет личный чат и разрешённый ID владельца.
-
Берёт блокировку каталога, чтобы два запроса не выпустили конфликтующие записи.
-
Создаёт отдельный журнал операции и новое имя.
-
Выполняет фиксированную операцию выпуска на Windows через SSH.
-
Забирает файл через SFTP со строгой проверкой ключа SSH-узла.
-
Сверяет SHA-256 файла, центр сертификатов, подпись и срок CRL, активность клиентского сертификата.
-
Добавляет профиль в каталог и отправляет документ владельцу.
При сетевой ошибке результат может быть неопределённым: сертификат уже выпущен, а подтверждение не дошло. Поэтому бот предлагает обновить меню и проверить наличие имени. Он не должен автоматически выпускать ещё один сертификат с той же идентичностью.
Метаданные Windows-каталога содержат источник, проверяемый узел, используемый выпуск и хеш CA. Это помогает не выдать файл из старого набора после изменения серверной PKI. Приватные ключи в журнал операций не записываются.
Отзыв сертификата и разрыв открытой сессии
У отзыва есть два результата, которые важно не склеивать в один:
-
новый вход по отозванному сертификату должен перестать проходить;
-
уже открытая сессия выбранного клиента должна быть прекращена, если это требуется операцией.
Вторая часть требует отдельного управления активным соединением. Мой Windows-код использует локальный management interface OpenVPN с паролем. Он слушает loopback, а не публичный интерфейс. В протоколе есть status 3 и kill конкретного CN; пароль и полный список клиентов в статью не вынесены.
Публично открывать этот интерфейс нельзя считать обычным способом администрирования: управляющий протокол не добавляет собственного защищённого транспорта. Это отдельно оговорено в документации management interface.
В Linux-учебной PKI отзыв выглядит так:
cd "$HOME/vpn-lab-pki"./easyrsa --batch revoke lab-phone./easyrsa --batch gen-crlopenssl crl -in pki/crl.pem -noout -verify -CAfile pki/ca.crtopenssl crl -in pki/crl.pem -noout -nextupdate
Затем новый подписанный CRL нужно доставить именно работающему экземпляру по его настроенному crl-verify, сохранив прежнюю версию и права чтения. Сам выпуск файла в каталоге CA ничего не меняет для сервера, если тот читает другой путь.
Приёмка отзыва — повторить попытку входа служебным профилем и получить отказ; при требовании немедленного отключения — отдельно увидеть завершение его прежней сессии. Для Windows-меню этот цикл был проверен; я сохранил его как сценарий дальнейших изменений.
Когда отключение активной сессии не подтверждено, бот честно сообщает частичный результат. Исходный профиль сохраняется в закрытом архиве, а не удаляется: наличие файла и действующий доступ — разные вещи.
AmneziaWG и Cloak в нынешней системе
На NL развёрнут AmneziaWG. Проверка установленных компонентов подтвердила инструмент и загруженный модуль версии 3.1.20260812, то есть серверную ветку 3.1.
AmneziaWG основан на WireGuard и добавляет изменения сетевых признаков протокола. У разработчиков есть описание протокола и версии 3.1. Для моей истории достаточно практического различия: это отдельный UDP-вариант, который не зависит от TCP-посредника OpenVPN.
Проверка версии без вывода ключей:
awg --versioncat /sys/module/amneziawg/versionip link show awg0systemctl show awg-quick@awg0.service bue-awg-nat.service \ --property=Id,ActiveState,SubState,NRestarts
Команда /awg в боте выдаёт заранее подготовленный .conf. Она не создаёт новый peer, не меняет маршруты и не генерирует новый ключ при нажатии кнопки.
Перед выдачей проверяются разрешённое имя, владелец файла, права 600, ограничение размера, отсутствие символьной ссылки и неизменность файла во время чтения. В Telegram отправляется снимок прочитанных байтов. Доступ ограничен разрешёнными владельцами в личном чате.
Версию сервера, версию приложения и параметры профиля нужно учитывать отдельно: они определяют доступные конкретному устройству возможности AmneziaWG.
На NL также работает cloak-server: есть процесс и TCP-слушатель. Cloak — транспорт для переноса трафика, который можно использовать вместе с другими программами; устройство проекта описано в его репозитории. В моей схеме это дополнительный серверный компонент; самостоятельный Windows VPN от него не зависит.
Отдельная инвентаризация файла Cloak показала одну запись в ProxyBook и StreamTimeout 300 секунд. В конфигурации AmneziaWG есть одна секция интерфейса и четыре секции peer, а также поля параметров маскировки. Это число настроенных записей, а не четыре активных устройства. Закрытые значения для описания архитектуры не требуются.
Что означают DNS-отчёты бота
Для /vpnsites в коде есть обработка журнала dnsmasq: клиент получает DNS внутри VPN, его туннельный IP сопоставляется с профилем, обращения агрегируются по дням. Позже в отчёт добавлены названия сервисов, свёртка фоновых обращений и фильтр по профилю.
Схематичный конфиг резолвера для учебной подсети:
interface=tun-labbind-dynamiclisten-address=10.77.0.1no-resolvserver=1.1.1.1server=8.8.8.8domain-neededbogus-privcache-size=1000log-querieslog-facility=/var/log/vpn-lab-dns.log
Проверка синтаксиса не запускает службу:
sudo dnsmasq --testsudo ss -lntup
Здесь необходимо проверить, что DNS не стал доступен всему интернету. Интерфейс и адрес в примере должны реально принадлежать учебному туннелю. Семантика настроек описана в руководстве dnsmasq.
У первоначального установочного сценария была конкретная несовместимость: bind-dynamic конфликтовал с bind-interfaces из другого файла dnsmasq. Это отражено в комментариях к исправлению. Поэтому dnsmasq --test и просмотр всех подключаемых настроек полезнее предположения, что новый маленький конфиг существует изолированно.
DNS-отчёт показывает запросы доменов. Он не показывает полный HTTPS URL, содержимое страницы или намерение человека открыть сайт. Запрос мог сделать фоновой процесс, приложение или предварительная загрузка браузера. Кеш уменьшает число повторных запросов, а приложения со своим DoH могут обходить этот журнал.
Блокировка обычного DNS и DoT на портах 53 и 853 не превращает отчёт в полный учёт сайтов: DoH может идти через HTTPS. Задержка DNS-ответа влияет на время открытия сайта, даже если основной трафик не проходит через резолвер. Её нужно измерять отдельно.
На NL есть status- и DNS-журналы. У статистики ограничен охват: DNS-запрос не равен посещению страницы, а Windows-маршрут не становится автоматически источником отчёта старого резолвера NL. Такой учёт требует понятной пользователям политики. При наличии этого механизма обещание «ничего не журналирую» было бы неверным.
Что работает сейчас
Система состоит из пользовательских профилей, двух серверных узлов и управления через Telegram:
|
Что работает |
Как устроено |
Практический результат |
|---|---|---|
|
VPN у пользователей |
Отдельные профили для устройств, выдача через бота и импорт в приложение |
У всех моих пользователей есть рабочее подключение и интернет |
|
Самостоятельный Windows VPN |
Локальный TCP-обработчик → OpenVPN → TAP → WinNAT |
Пользовательский трафик выходит через Windows и не проходит через NL |
|
OpenVPN на NL |
Три действующих экземпляра с UDP- и TCP-вариантами входа |
Сохранены отдельные способы подключения через Linux-узел |
|
Telegram-бот |
Меню Windows-профилей, создание, доставка и отзыв; управление Windows по SSH/SFTP |
Профиль можно получить через привычный интерфейс, без ручного поиска на сервере |
|
Раздельный доступ |
Собственные сертификаты устройств, каталог, журнал операций и CRL |
Один профиль можно отозвать отдельно от остальных |
|
Два аккаунта управления |
Разрешённые числовые Telegram ID и личные чаты |
Управление доступно из обоих моих аккаунтов |
|
AmneziaWG на NL |
Отдельный UDP-контур, подготовленные клиентские конфиги и выдача через |
Есть дополнительный вариант подключения; кнопка отдаёт готовый профиль |
|
Дополнительные компоненты NL |
Cloak, status-журналы, сбор DNS-агрегатов |
Транспортные возможности и диагностика отделены от основного Windows-пути |
На NL три действующих OpenVPN-конфига используют TLS не ниже 1.2, tls-crypt, CRL, AES-256-GCM:AES-128-GCM и запрет сжатия. В файлах также есть auth SHA512. При согласованном GCM этот параметр не задаёт дополнительную аутентификацию канала данных: её выполняет сам AEAD-шифр, что поясняет руководство OpenVPN. UDP-вариант содержит multihome, а два основных входа используют keepalive 10 120. Совместимый TCP-экземпляр отдельно направляет и блокирует IPv6; директивы keepalive в его файле нет.
OpenVPN-процессы на NL были active/running, без перезапусков в их текущих счётчиках. Работал бот; три основных файла его кода совпали с зафиксированной версией репозитория. Windows-проверка показывала два клиентских подключения, растущий трафик и NAT-сеансы.
Для сравнения установленного кода не обязательно скачивать его вместе с конфигурацией. На NL Git blob-хеши трёх файлов были сравнены с файлами в зафиксированном коммите репозитория:
git hash-object /opt/bue/core/bue_brain.pygit hash-object /opt/bue/core/bue_vpn.pygit hash-object /opt/bue/core/bue_windows_vpn.py
Хеши позволяют определить установленную редакцию. Для пользовательской проверки нужно получить профиль из бота, импортировать его и открыть сайт.
Для AmneziaWG active/exited у awg-quick нормально для завершившейся задачи настройки интерфейса. OpenVPN я проверял по процессам и слушающим сокетам. Признаки штатной работы у этих программ разные; после серверных проверок всё равно нужно подключение с устройства.
Три старые службы накопили по двадцать шесть тысяч рестартов
Во время проверки обнаружилось неприятное соседство. Три нужных OpenVPN-процесса работали, а три других, старых unit были включены и постоянно пытались стартовать без ожидаемых конфигураций.
В первой проверке у каждого было около 25,3 тысячи перезапусков. При повторной сверке счётчики выросли до 26 173–26 174. Причина конкретных падений видна — отсутствуют ожидаемые файлы; почему эти файлы исчезли и нужны ли ещё прежние имена служб, отдельно не установлено.
На корневом диске оставалось около 6,1 ГиБ при общем размере 40 ГиБ и занятости 84%. Общий systemd-journal занимал 1,1 ГиБ. Приписать весь журнал трём падающим службам без раздельного измерения нельзя.
Команды, которыми такой дефект обнаруживается:
systemctl list-units --all 'openvpn*' --no-pagersystemctl list-unit-files 'openvpn*' --no-pagersystemctl show \ openvpn-server@bue.service openvpn@bue.service openvpn@server.service \ --property=Id,ActiveState,SubState,UnitFileState,NRestarts,ExecMainStatussudo journalctl -u openvpn-server@bue.service -n 8 --no-pagersudo journalctl -u openvpn@bue.service -n 8 --no-pagersudo journalctl -u openvpn@server.service -n 8 --no-pagerjournalctl --disk-usagedf -h /
Эти три имени относятся к найденным неисправным службам моей установки. У другого администратора те же имена могут обозначать единственный нужный VPN. Следующий шаг — сверить зависимости и прежних потребителей, сохранить состояние и решить: восстановить конфиги или вывести устаревшие unit из автозапуска.
Три старых unit остались отдельным техническим долгом. Сбросить счётчик неудачных запусков недостаточно: после проверки зависимостей нужно устранить причину повторных стартов.
Как теперь выглядит приёмка VPN
Для новых изменений я использую одну последовательность, чтобы сравнивать результаты. В ней три части: выпуск доступа, сеть и отзыв.
Выпуск и доставка
Сначала нужно создать отдельный служебный профиль и проверить имя, уникальность, сертификат, принадлежность нужной CA и наличие в каталоге. Затем получить файл через тот же путь в боте, которым пользуется владелец. Наличие .ovpn на сервере не гарантирует, что меню отдаёт именно его.
Профиль нужно импортировать как новый. Старые файлы лучше сохранить отдельно с понятным названием: совпадающие отображаемые имена могут скрывать другую CA или другой маршрут.
Сеть на настоящем устройстве
Для сравнения нужны устройство, сеть, время и профиль. При проверке прямого пути другой VPN должен быть отключён, в том числе на роутере. Один из моих ранних домашних Wi-Fi-тестов оказался вложенным в уже работающий VPN роутера. Без этого уточнения сравнение было бы неверным.
Дальше — проверки на устройстве:
|
Шаг |
Критерий |
|---|---|
|
Внешний IPv4 |
Совпадает с ожидаемым серверным выходом выбранного маршрута |
|
IPv6 |
Идёт через ожидаемый выход либо блокируется согласно выбранной политике |
|
DNS |
Имена разрешаются ожидаемым способом; проверка внешнего IP этого не заменяет |
|
HTTPS |
Открываются нужные сайты, передаётся файл |
|
Время |
Не менее пяти минут передачи в конкретном тесте; отдельно простой и восстановление |
|
Повтор |
Несколько новых подключений, без необходимости «со второй попытки» |
|
Мобильность |
Wi-Fi → LTE → Wi-Fi и блокировка экрана |
|
Скорость |
Тот же тестовый сервер, одинаковые условия, несколько замеров и сравнение без VPN |
Пять минут — мой минимальный критерий приёмки изменения, а не доказательство недельной устойчивости.
На ноутбуке полезны такие команды:
# Внешний адрес и HTTPS через выбранный маршрут.curl -4 --fail --silent --show-error --max-time 15 \ https://1.1.1.1/cdn-cgi/trace# При политике блокировки IPv6 ожидается отказ, а не обычный адрес провайдера.curl -6 --fail --silent --show-error --max-time 15 \ https://cloudflare.com/cdn-cgi/trace# DNS операционной системы — это не полная проверка отсутствия DNS-утечек.getent ahosts example.com
В Windows для настоящего curl, а не старого PowerShell-псевдонима:
curl.exe -4 --fail --silent --show-error --max-time 15 ` https://1.1.1.1/cdn-cgi/tracecurl.exe -6 --fail --silent --show-error --max-time 15 ` https://cloudflare.com/cdn-cgi/traceResolve-DnsName example.comGet-DnsClientServerAddress -AddressFamily IPv4
Ответ trace показывает внешний адрес конкретного HTTP-запроса. Совпадение IPv4 не доказывает, что другое приложение не использует собственный DNS или что весь IPv6 тоже защищён. Для проверки DNS нужен контролируемый запрос и наблюдение за ожидаемым резолвером либо отдельный клиентский тест утечек с пониманием его охвата.
На iPhone ту же страницу адреса я открываю при включённом нужном профиле. В одной из ранних попыток страница с обычным мобильным IP открылась между двумя VPN-соединениями. Без сопоставления времени такой скриншот легко принять за результат работы туннеля.
Отзыв и проверка остальных устройств
Нужно отозвать именно служебный профиль, проверить обновлённый CRL, отказ нового подключения и, при необходимости, прекращение прежней сессии. Затем проверить второй действующий служебный профиль: отзыв первого не должен лишить доступа остальных.
Удаление кнопки из Telegram-меню не заменяет этот опыт. Как и отсутствие файла на диске: копия .ovpn может оставаться у пользователя.
После запуска: обслуживание и развитие
В плане сопровождения системы остаются следующие задачи:
-
Разобрать три устаревших OpenVPN unit, сверить зависимости и прекратить бесполезные старты.
-
Доработать источник событий
/vpnlogи охват нужных экземпляров, добавить автоматический алерт шторма переподключений. -
Автоматизировать проверки старого генератора, которые пока выполнялись вручную: CN, issuer, назначение сертификата и совпадение ключа.
-
Сохранять единый сценарий приёмки новых изменений: устройства, сети, повторные подключения, фон iPhone и политика IPv6.
-
Регулярно проверять Windows firewall, доступ к административным портам и изоляцию клиентов.
-
Следить за сертификатами и CRL, проверять резервные копии через восстановление.
Выпуск и обновление Windows CRL используются в установленной схеме. В сопровождении нужно отдельно контролировать расписание обновления и проверять результат: наличие нового файла в каталоге ещё не означает, что нужный сервер читает именно его.
Для следующих экспериментов у меня есть рабочая исходная версия и понятный откат. Новый транспорт или ускорение я добавляю рядом с ней и проверяю на тех же устройствах.
К чему я пришёл
Я начинал с генератора четырёх файлов и простого меню. Получилась система с отдельными сертификатами, подтверждаемой выдачей, двумя разными OpenVPN-маршрутами через Windows и серверными вариантами подключения на NL. Windows самостоятельно выпускает пользовательский трафик в интернет, Telegram служит средством управления.
Теперь у меня есть порядок диагностики: сертификат, адрес туннеля, TCP-поток, DNS, NAT, внешний IP и проверка приложения на устройстве. Он помогает выбрать небольшое изменение, проверить результат и при необходимости откатить его.
Сейчас все, кому я выдал профили, пользуются VPN. Новое устройство получает отдельный доступ через бота; для этого больше не нужно вручную искать файл на сервере. Windows самостоятельно выпускает трафик в интернет, а на NL сохранены управление и дополнительные варианты подключения.
Приложение. Полные исходники
Ниже три файла для учебного Windows-узла. TCP-обработчик и его тест нужно сохранить в одном каталоге. PKI-сценарий рассчитан на отдельный пустой защищённый каталог; он не исправляет существующую CA и не устанавливает OpenVPN.
Локальный TCP-обработчик — local_tcp_entry.py
"""TCP entry for a Windows-local OpenVPN backend; never connects to NL."""import argparseimport ipaddressimport loggingimport logging.handlersimport osimport selectimport signalimport socketimport threadingimport timeclass Entry: def __init__(self, address, backend_port, delay=0.02, warmup_seconds=10.0, warmup_bytes=16384): self.address = address self.backend = ('127.0.0.1', backend_port) self.delay = delay self.warmup_seconds = warmup_seconds self.warmup_bytes = warmup_bytes self.stop = threading.Event() self.slots = threading.BoundedSemaphore(8) self.listener = None self.ready = threading.Event() @staticmethod def prepare(sock): sock.settimeout(5) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) if os.name == 'nt': sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 60000, 10000)) def forward(self, data, destination, transferred, elapsed): # Keep the complete startup exchange protected in each direction. # Both thresholds must pass before removing per-block waits. if elapsed >= self.warmup_seconds and transferred >= self.warmup_bytes: destination.sendall(data) return True destination.sendall(data[:1]) if len(data) > 1: if self.stop.wait(self.delay): return False destination.sendall(data[1:]) return True def connection(self, client): backend = None sent = received = 0 started = last_activity = time.monotonic() try: self.prepare(client) backend = socket.create_connection(self.backend, timeout=5) self.prepare(backend) while not self.stop.is_set(): now = time.monotonic() if (received == 0 and now - started > 15) or now - last_activity > 7200: break readable, _, _ = select.select([client, backend], [], [], 0.5) for origin in readable: data = origin.recv(65536) if not data: return transferred = sent if origin is client else received if not self.forward(data, backend if origin is client else client, transferred, time.monotonic() - started): return last_activity = time.monotonic() if origin is client: sent += len(data) else: received += len(data) except (OSError, ValueError) as error: logging.info('Connection ended: %s', type(error).__name__) finally: client.close() if backend is not None: backend.close() self.slots.release() logging.info('Connection bytes sent=%d received=%d', sent, received) def run(self): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: self.listener = sock if os.name == 'nt': sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) sock.bind(self.address) sock.listen(8) sock.settimeout(0.5) self.ready.set() logging.info('Windows-local VPN entry started') try: while not self.stop.is_set(): try: client, _ = sock.accept() except socket.timeout: continue if not self.slots.acquire(blocking=False): client.close() continue threading.Thread(target=self.connection, args=(client,), daemon=True).start() finally: self.stop.set()def main(): parser = argparse.ArgumentParser() parser.add_argument('--listen-ip', required=True) parser.add_argument('--listen-port', type=int, default=5444) parser.add_argument('--backend-port', type=int, default=15444) parser.add_argument('--log', required=True) args = parser.parse_args() address = ipaddress.IPv4Address(args.listen_ip) if address.is_unspecified or address.is_multicast or address.is_loopback: parser.error('An assigned external Windows IPv4 is required') if not 1024 <= args.listen_port <= 65535 or not 1024 <= args.backend_port <= 65535: parser.error('Ports must be between 1024 and 65535') handler = logging.handlers.RotatingFileHandler( args.log, maxBytes=1024 * 1024, backupCount=3, encoding='utf-8') logging.basicConfig(level=logging.INFO, handlers=[handler], format='%(asctime)s %(levelname)s %(message)s') entry = Entry((str(address), args.listen_port), args.backend_port) signal.signal(signal.SIGINT, lambda *_: entry.stop.set()) signal.signal(signal.SIGTERM, lambda *_: entry.stop.set()) entry.run()if __name__ == '__main__': main()
Три теста TCP-обработчика — test_local_tcp_entry.py
"""End-to-end byte preservation in both directions over real loopback TCP."""import socketimport threadingimport unittestfrom unittest.mock import Mockfrom local_tcp_entry import Entrydef receive_exact(sock, size): chunks = [] while size: chunk = sock.recv(min(size, 65536)) if not chunk: raise AssertionError('Premature EOF') chunks.append(chunk) size -= len(chunk) return b''.join(chunks)class LocalEntryTest(unittest.TestCase): def test_binary_roundtrip_and_shutdown(self): self.roundtrip(512, {}) def test_fast_path_large_binary_roundtrip(self): self.roundtrip(32768, {'warmup_seconds': 0, 'warmup_bytes': 0}) def test_full_startup_is_protected_and_bulk_has_no_wait(self): entry = Entry(('127.0.0.1', 0), 1) entry.stop = Mock() entry.stop.wait.return_value = False for transferred, elapsed in ((2048, 20), (1000000, 1)): destination = Mock() self.assertTrue(entry.forward(b'hello', destination, transferred, elapsed)) self.assertEqual([c.args[0] for c in destination.sendall.call_args_list], [b'h', b'ello']) entry.stop.reset_mock() destination = Mock() self.assertTrue(entry.forward(b'hello', destination, 1000000, 20)) destination.sendall.assert_called_once_with(b'hello') entry.stop.wait.assert_not_called() def roundtrip(self, blocks, settings): request = bytes(range(256)) * blocks response = request[::-1] failures = [] with socket.socket() as backend: backend.bind(('127.0.0.1', 0)) backend.listen(1) backend.settimeout(5) def serve(): try: with backend.accept()[0] as peer: peer.settimeout(5) self.assertEqual(receive_exact(peer, len(request)), request) peer.sendall(response) except Exception as error: failures.append(error) server_thread = threading.Thread(target=serve) server_thread.start() entry = Entry(('127.0.0.1', 0), backend.getsockname()[1], **settings) entry_thread = threading.Thread(target=entry.run) entry_thread.start() try: self.assertTrue(entry.ready.wait(3)) self.assertEqual(entry.backend[0], '127.0.0.1') with socket.create_connection(entry.listener.getsockname(), timeout=5) as client: client.sendall(request) self.assertEqual(receive_exact(client, len(response)), response) finally: entry.stop.set() entry_thread.join(3) server_thread.join(3) self.assertFalse(entry_thread.is_alive()) self.assertFalse(server_thread.is_alive()) self.assertEqual(failures, [])if __name__ == '__main__': unittest.main()
Выпуск учебной PKI на Windows — New-LabPki.ps1
# Отдельная PKI пилота. Ничего не перезаписывает и не запускает сервер.[CmdletBinding()]param( [string]$Root = 'C:\vpn-lab\windows-openvpn', [string]$OpenSsl = 'C:\Program Files\OpenVPN\bin\openssl.exe', [string]$OpenVpn = 'C:\Program Files\OpenVPN\bin\openvpn.exe')$ErrorActionPreference = 'Stop'$ProgressPreference = 'SilentlyContinue'foreach ($binary in @($OpenSsl, $OpenVpn)) { if (!(Test-Path -LiteralPath $binary -PathType Leaf)) { throw "MissingBinary: $binary" } if ((Get-AuthenticodeSignature -LiteralPath $binary).Status -ne 'Valid') { throw "BinarySignatureInvalid: $binary" }}$pki = Join-Path $Root 'pki'if (!(Test-Path -LiteralPath $pki -PathType Container)) { throw 'PrivatePilotRootMissing' }if (@(Get-ChildItem -LiteralPath $pki -Force).Count -ne 0) { throw 'PkiNotEmptyInspectBeforeRetry' }$acl = Get-Acl -LiteralPath $Rootif (!$acl.AreAccessRulesProtected) { throw 'RootAclMustBeProtected' }foreach ($rule in $acl.Access) { $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value if ($rule.AccessControlType -eq 'Allow' -and $sid -notin @('S-1-5-18','S-1-5-32-544')) { throw 'UnexpectedPrincipalInPrivateRootAcl' }}function Write-NewAscii([string]$Path, [string]$Text) { # CreateNew защищает от случайного повторного выпуска с затёртыми ключами. $stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write) try { $bytes = [Text.Encoding]::ASCII.GetBytes($Text.Replace("`r`n", "`n")) $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Dispose() }}function Invoke-Crypto([string[]]$CryptoArgs) { # Утилита пишет прогресс в stderr. Сохраняем его только в закрытом файле. $prior = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { & $OpenSsl @CryptoArgs >> (Join-Path $Root 'logs\pki-build.log') 2>&1; $code = $LASTEXITCODE } finally { $ErrorActionPreference = $prior } if ($code -ne 0) { throw "OpenSslFailed: $code (see private pki-build.log)" }}$path = $pki.Replace('\','/')$config = @"[ ca ]default_ca = pilot_ca[ pilot_ca ]dir = $pathdatabase = `$dir/index.txtnew_certs_dir = `$dir/issuedcertificate = `$dir/ca.crtprivate_key = `$dir/ca.keyserial = `$dir/serialcrlnumber = `$dir/crlnumberdefault_md = sha256default_days = 365default_crl_days = 30policy = pilot_policyunique_subject = yescopy_extensions = none[ pilot_policy ]commonName = supplied[ req ]distinguished_name = req_dnprompt = no[ req_dn ]CN = Lab Windows VPN CA[ ca_extensions ]basicConstraints = critical,CA:TRUE,pathlen:0keyUsage = critical,keyCertSign,cRLSignsubjectKeyIdentifier = hashauthorityKeyIdentifier = keyid:always[ server_extensions ]basicConstraints = critical,CA:FALSEkeyUsage = critical,digitalSignature,keyEnciphermentextendedKeyUsage = serverAuthsubjectKeyIdentifier = hashauthorityKeyIdentifier = keyid,issuer[ client_extensions ]basicConstraints = critical,CA:FALSEkeyUsage = critical,digitalSignatureextendedKeyUsage = clientAuthsubjectKeyIdentifier = hashauthorityKeyIdentifier = keyid,issuer"@New-Item -ItemType Directory -Path (Join-Path $pki 'issued') | Out-NullWrite-NewAscii (Join-Path $pki 'openssl.cnf') $configWrite-NewAscii (Join-Path $pki 'index.txt') ''Write-NewAscii (Join-Path $pki 'serial') "1000`n"Write-NewAscii (Join-Path $pki 'crlnumber') "1000`n"$cfg = Join-Path $pki 'openssl.cnf'Push-Location $pkitry { Invoke-Crypto -CryptoArgs @('genpkey','-algorithm','RSA','-pkeyopt','rsa_keygen_bits:3072','-out','ca.key') Invoke-Crypto -CryptoArgs @('req','-new','-x509','-key','ca.key','-sha256','-days','1095','-config',$cfg,'-extensions','ca_extensions','-out','ca.crt') foreach ($entry in @(@('server','lab-windows-server','server_extensions'),@('client','lab-phone','client_extensions'))) { $name = $entry[0] Invoke-Crypto -CryptoArgs @('genpkey','-algorithm','RSA','-pkeyopt','rsa_keygen_bits:3072','-out',"$name.key") Invoke-Crypto -CryptoArgs @('req','-new','-key',"$name.key",'-subj',('/CN='+$entry[1]),'-config',$cfg,'-out',"$name.csr") Invoke-Crypto -CryptoArgs @('ca','-batch','-notext','-config',$cfg,'-extensions',$entry[2],'-in',"$name.csr",'-out',"$name.crt") } Invoke-Crypto -CryptoArgs @('ca','-batch','-config',$cfg,'-gencrl','-out','crl.pem') Invoke-Crypto -CryptoArgs @('verify','-CAfile','ca.crt','-CRLfile','crl.pem','-crl_check','-purpose','sslserver','server.crt') Invoke-Crypto -CryptoArgs @('verify','-CAfile','ca.crt','-CRLfile','crl.pem','-crl_check','-purpose','sslclient','client.crt') & $OpenVpn --genkey secret (Join-Path $pki 'tls-crypt.key') if ($LASTEXITCODE -ne 0) { throw 'TlsCryptGenerationFailed' } Write-NewAscii (Join-Path $pki 'generation-complete.json') ('{"client":"lab-phone","server":"lab-windows-server","profiles":1}') [pscustomobject]@{Pki='Ready';ClientCertificates=1;PrivateMaterialPrinted=$false} | ConvertTo-Json -Compress} finally { Pop-Location }
Спасибо, что дочитали. До встречи на Хабре!
ссылка на оригинал статьи https://habr.com/ru/articles/1104028/