Перейти к содержанию

Кейс · Скорость и инфраструктура, Веб и SaaS

EdgeShift своя замена прокси Cloudflare

Панель и агенты, которые прячут настоящий сервер сайтов за промежуточными серверами — фронтами — и меняют публичный адрес, когда фронт атакуют. Трафик фронт не расшифровывает: ключи TLS остаются у владельца сайтов.

35версий за две недели после запуска — с 1.0.4 до 1.21.1
404автотеста, 7 из них — вживую, против настоящих программ в Docker
10инструментов MCP: без полного токена ассистент панель не меняет
1прямая внешняя зависимость у четырёх программ на Go

Паспорт проекта

Отрасль
Хостинг сайтов
Срок
2,5 недели
Команда
Команда E404 и ИИ-агент

Стек

GonginxWireGuard (Headscale)resticMariaDBCloudflare DNSMCP

Задача

У владельца — сервер с несколькими десятками сайтов на WordPress и MODX: nginx и PHP 8.4. DNS — в Cloudflare, но у части сайтов записи вели прямо на сервер, без прокси. Поэтому настоящий адрес сервера виден любому, кто спросит DNS, и атака на любой из этих сайтов приходит прямо на сервер — и задевает все сайты сразу.

Нужна была своя замена ровно одной функции Cloudflare. Посетители видят только адрес промежуточного сервера — фронта. Настоящий сервер отвечает через туннель. Когда фронт атакуют, его адрес в DNS меняется на резервный.

Ограничения были такие:

  • Живые сайты. Ни один наш шаг не должен их уронить — даже на минуту.
  • Ключи остаются у владельца. Фронт не расшифровывает TLS, поэтому видит только адрес клиента, имя сайта и то, как устанавливается соединение.
  • Разные хостеры. Общего API у провайдеров фронтов нет — общий у них только SSH.
  • Одна панель на всех заказчиков. Каждый видит только своё.

Все экраны в кейсе — со стенда с выдуманными данными: веб-студия «Кедр», сайты на .example, адреса из диапазонов для документации. Стенд собран из кода проекта, версия 1.21.1. Боевую панель для кейса не снимали.

Обзор панели EdgeShift: блок «Требует внимания» с тремя пунктами, счётчики фронтов, сайтов и копий, пул фронтов и последние события
Всё, что требует внимания, — на первом экране: утечка в DNS, сайты без туннеля, сертификат на исходе

Что сделали

Сайт за фронтом — из подсказки панели

На сервер сайтов ставится агент. Он читает конфигурацию nginx и сообщает панели, какие сайты там живут. На странице домена панель сама предлагает записи для этих имён, а проверка утечек показывает, какие записи ещё выдают настоящий адрес. Два нажатия — и домен смотрит на фронт.

Страница домена: записей нет, блок «Найдено на серверах сайтов» с двумя именами и кнопками «Добавить запись», проверка утечек — две утечки, адрес сервера 203.0.113.50
В панели записей ещё нет, а она уже нашла эти имена на сервере и видит в DNS две утечки
Страница домена: две записи A с адресом фронта 203.0.113.11, статус «Проксируется», origin скрыт; проверка утечек — «Утечек нет», обе записи — «Активный фронт»
Записи ведут на фронт, проверка утечек чистая

Смена адреса за одно подтверждение

  1. Оператор нажимает «Сменить IP» у фронта, который сейчас в DNS.
  2. Панель объясняет, что будет, и ждёт подтверждения.
  3. Старый адрес списывается, в DNS встаёт резервный фронт.
  4. Прежний фронт ещё несколько минут дослуживает тех, у кого в кэше старый ответ DNS.

Сайт при этом никуда не переезжает: меняется только адрес, который видят посетители. Если фронт перестаёт отвечать, панель меняет адрес сама, а после смены напоминает, что резерв пора пополнить.

Окно «Сменить IP фронта edge-fi-1?»: текущий адрес будет списан, в DNS попадёт резервный фронт; кнопки «Отмена» и «Сменить IP»
Перед сменой панель говорит, что будет с адресом
Сообщение «IP сменён»; edge-nl-1 в DNS, edge-de-1 в резерве, edge-fi-1 «Списан · дослуживает»; предупреждение «в резерве готовы 1 из 2 фронтов»
В DNS — резервный фронт, прежний списан и дослуживает старые соединения
Смена адреса — одно подтверждение: через секунды в DNS резервный фронт

Видно, кто атакует, — и его можно отрезать

Панель считает новые соединения на каждом фронте. Когда их больше порога пула, на обзоре появляется плашка с кнопкой смены адреса. На странице «Фильтр» видно, кто шумит: адреса и отпечатки TLS (JA4) с долей соединений. Блокировка — кнопкой в той же строке. Фронты применяют правило за секунды, и заблокированный клиент до сервера сайтов не доходит вовсе.

Красная плашка «Возможная атака на edge-nl-1: 59.7 подкл/с при пороге 40.0» с кнопкой «Сменить IP»; счётчик подключений в секунду выделен красным
Тревога: подключений в секунду больше порога пула
Таблицы «Кто шумит» (203.0.113.66 — 4090 соединений, 64%) и «Отпечатки TLS» (t13d131100_… — 64%), у каждой строки кнопка «Заблокировать»
Бот выделяется и адресом, и отпечатком TLS — на нём почти две трети соединений
Карточка фронта: 60 подключений в секунду, «закрыто правилами 5308», отметка «атака?» и подсказка сменить IP
Фронт под атакой: соединения идут, но фильтр их закрывает

Сервер сайтов под присмотром

Агент ставится в одном из трёх режимов: «только наблюдение» (по умолчанию), «наблюдение и копии» или «изменения из панели». На карточке сервера — туннель, nginx, подключённые сайты, сертификаты и прямой доступ. Закрыть прямой доступ к серверу панель не даст, пока хоть одно имя сайта ведёт на него напрямую, — иначе файрвол уронил бы сайты, которые ещё не перевели.

Карточка сервера: агент на связи, вход из туннеля — самопроверка пройдена, 4 сайта подключено и 7 нет; справа кнопка «Закрыть» неактивна, ниже список имён, которые ещё ведут на сервер
Закрыть прямой доступ нельзя, пока хоть один сайт ведёт на сервер напрямую

Модуль SiteShape показывает все сайты сервера — приложение, PHP, сертификат и вход из туннеля. Файл nginx можно поправить прямо из панели: агент прогонит nginx -t, опросит сайты до и после правки и при расхождении её откатит. Там же — логи сайта.

Таблица сайтов: WordPress и MODX на PHP 8.2, отметки «подключён»/«не подключён», сроки сертификатов, у turbaza-baikal.example — 8 дней, у remont-kvartir.example — кнопка «Сертификат»
Все сайты сервера: приложение, PHP, вход из туннеля и срок сертификата

Копии сайтов и баз

Модуль SafeHold копирует сервер сайтов в хранилище заказчика: S3, SFTP или REST. Дампы баз WordPress и MODX агент снимает сам — реквизиты находит в настройках сайтов. Расписание тоже держит агент, поэтому копии идут, даже когда панель недоступна. Снимок возвращается в отдельный каталог, живые файлы не трогаются, а раз в неделю агент пробует вернуть выборку и проверяет хранилище.

Страница плана: снимки d91edca5 и 19fee61b по 71 файлу и 55,8 КБ с кнопками «Вернуть в каталог», история копий с числом дампов баз
Любой снимок можно вернуть в отдельный каталог — живые файлы не трогаются

Заказчики и ИИ-ассистент

Одна панель рассчитана на нескольких заказчиков. Каждый видит только свои фронты, домены и журнал, а поставщик видит всех сразу — и у кого какие проблемы. Ассистент вроде Claude подключается к панели по MCP. С токеном «только чтение» он смотрит состояние и проблемы, а любую попытку что-то изменить отклоняет сама панель.

Блок «Что болит» по заказчикам и таблица заказчиков: фронты, домены, серверы, пользователи, активность, срок
Одна панель на всех заказчиков: у кого какие проблемы и чей срок кончается

Как устроено

Схема: поставщик, заказчики и ИИ-ассистент по MCP с токеном «только чтение» или полным работают с панелью EdgeShift. Панель пишет A-записи у DNS-провайдера (Cloudflare, PowerDNS, deSEC). Фронту она отдаёт правила и получает от него нагрузку и список шумных клиентов, агенту сервера сайтов по туннелю — команды, а от него — отчёты. Посетители узнают в DNS адрес фронта; при атаке вместо него встают резервные фронты у других провайдеров. Фронт читает имя сайта из TLS без расшифровки, фильтрует по адресу и отпечатку JA4 и передаёт соединение по туннелю WireGuard с заголовком PROXY на сервер сайтов (nginx, PHP, WordPress и MODX). Агент сервера сайтов ведёт опись сайтов, ищет утечки, правит nginx с откатом и отправляет файлы и дампы баз через restic в хранилище копий: S3, SFTP или REST.
Посетители идут с фронта прямо на сервер сайтов; панель управляет фронтами, DNS и агентом, но на пути трафика не стоит

Панель, агент фронта, агент сервера сайтов и сервер MCP — четыре программы на Go с одной прямой внешней зависимостью на всех. Фронт — это отдельный VPS с агентом. Он принимает соединение, читает из приветствия TLS (ClientHello) имя сайта, проверяет фильтр и отправляет байты по туннелю на сервер сайтов, добавив заголовок PROXY с адресом посетителя. nginx верит этому заголовку только из сети туннеля.

Агенты ходят в панель по туннелю, а сама панель на пути трафика не стоит: посетители идут с фронта прямо на сервер сайтов. DNS остаётся у провайдера заказчика — панель меняет только те записи, которые ей поручили.

От чего отказались:

  • От WAF на фронте. Для него нужны ключи TLS, а они остаются у владельца. Проверки по содержимому запросов делаются на сервере сайтов.
  • От ограничения частоты по умолчанию. Механизм готов, но выключен: слишком малый порог отрежет посетителей за NAT мобильных операторов.

Как панель понимает, что фронт жив. Проверки порта мало: старый адрес фронта мог уже достаться чужому серверу, и тот ответит. Поэтому панель шлёт на адрес фронта запрос, подписанный ключом из его токена, и ждёт ответа с подписью фронта. Чужой сервер так не ответит. А сканеру фронт себя не выдаст: на неподписанный запрос он отвечает, как на запрос к неизвестному сайту.

Инженерные решения

Не уронить чужие сайты

Любая правка nginx на сервере с живыми сайтами — риск для всех сразу. Поэтому агент не переписывает конфигурацию, а добавляет одну строку include в каждый подключаемый блок server — вход из туннеля с заголовком PROXY. Порядок всегда один: план без изменений, опрос сайтов, копия конфигурации, запись, nginx -t, мягкая перезагрузка, самопроверка через туннель. Потом агент снова опрашивает сайты и при любом расхождении с ответами «до» откатывает изменения:

e.Step("Сравниваю ответы сайтов до и после")
after := ProbeAll(ctx, public)
if regs := Compare(ctx, public, before, after); len(regs) > 0 {
	var lines []string
	for _, r := range regs {
		lines = append(lines, fmt.Sprintf("%s:%d было %s, стало %s",
			r.Target.Name, r.Target.Port, r.Before, r.After))
	}
	return e.rollback(ctx, n, ap, public, backup,
		"сайты стали отвечать иначе: "+strings.Join(lines, "; "))
}

Так выглядит итог на стенде — четыре сайта подключены, ответы совпали (вывод edgeshift-origin setup дословно):

==> Сравниваю ответы сайтов до и после
    сайт                          порт  до                    после                 через туннель
    avtoservis-garazh.example     80    301                   301                   301
    avtoservis-garazh.example     443   200                   200                   200
    bulochnaya-sdoba.example      80    301                   301                   301
    bulochnaya-sdoba.example      443   200                   200                   200
    kedr-studio.example           80    301                   301                   301
    kedr-studio.example           443   200                   200                   200
    stomatologiya-ulybka.example  80    301                   301                   301
    stomatologiya-ulybka.example  443   200                   200                   200
==> Готово: сайты открываются через туннель, прямой доступ работает как раньше

Не всё нашлось сразу. На сервере заказчика nginx подключал conf.d раньше каталога сайтов, поэтому каталог сайтов агенту пришлось искать по имени, а не по порядку подключения. А живой прогон против настоящего nginx в Docker показал, что откат возвращал файлы, не дождавшись, пока nginx перечитает конфигурацию. Оба случая исправлены, а ошибку с откатом теперь ловит живой прогон.

Фронт не расшифровывает TLS

Чтобы отправить соединение нужному сайту, фронт должен знать его имя, а ключей у него нет. Мы отдаём приветствие клиента (ClientHello) стандартному разбору TLS из Go на соединении, в которое нельзя писать. Как только имя прочитано, фронт прекращает разбор, а прочитанные байты отправляет на сервер сайтов как есть — рукопожатие с клиентом доводит уже сервер. Отпечаток JA4 считается здесь же: кроме адреса, это единственное, что фронт знает о клиенте.

func peekSNI(r io.Reader) (sni, ja4 string, replay io.Reader, err error) {
	var peeked bytes.Buffer
	captured := false
	limited := io.LimitReader(r, maxHelloBytes)
	err = tls.Server(readOnlyConn{io.TeeReader(limited, &peeked)}, &tls.Config{
		GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
			sni, ja4 = hello.ServerName, JA4(hello)
			captured = true
			return nil, errHelloCaptured
		},
	}).Handshake()
	if !captured {
		return "", "", nil, fmt.Errorf("read client hello: %w", err)
	}
	return sni, ja4, io.MultiReader(&peeked, r), nil
}

Получилось, что сертификаты живут на сервере сайтов, как и раньше, а фильтр на фронте умеет ровно то, что позволяет такая схема: адреса, подсети и отпечатки TLS. Это свойство продукта, а не недоделка.

Тесты были зелёными, а вёрстка — нет

Политика безопасности панели (CSP) запрещает встроенные стили, и браузер молча выбрасывал все атрибуты style в шаблонах — их было 202. Тесты рендеринга это не ловили: страница собиралась, код ответа 200. Расхождение заметили глазами: карточка установки агента наезжала сама на себя.

Карточка установки в 1.21.0: команды вылезают за границы карточек режимов, текст обрезан справа
До, версия 1.21.0: CSP выбрасывала встроенные стили, и карточка наезжала сама на себя
Карточка установки в 1.21.1: три карточки режимов одинаковой высоты, под ними одна команда установки и кнопка «Копировать»
После, версия 1.21.1: три режима в ряд и одна команда — для выбранного

Все 202 атрибута стали классами, а чтобы история не повторилась, тест падает на любом style= в шаблонах:

reStyle := regexp.MustCompile(`(?:\s|\}\})style\s*=`)
for _, f := range files {
	b, err := os.ReadFile(f)
	if err != nil {
		t.Fatal(err)
	}
	for i, line := range strings.Split(string(b), "\n") {
		if reStyle.MatchString(line) {
			t.Errorf("%s:%d: атрибут style — CSP (style-src 'self') его не применит; заведите класс в panel.css", f, i+1)
		}
	}
}

Результат

EdgeShift работает в бою с сентября 2026 года и обслуживает первого заказчика: на его сервере 54 сайта, два из них — за фронтом (по журналу проекта). Код — 35 версий за две недели после запуска и 404 автотеста; прогон тестов при подготовке кейса прошёл без ошибок.

Два сайта уже работают через фронт и адрес сервера прячут. Остальные 52 пока работают мимо фронта, и у части из них записи в DNS по-прежнему выдают адрес сервера. Смена адреса тоже ждёт второго фронта в резерве. Оба шага — за владельцем: нужен VPS у другого провайдера и перевод оставшихся сайтов. Закрыть прямой доступ раньше времени панель не даст.

Поэтому атаку показываем на стенде: настоящие программы, выдуманные данные. Бот открывал 60 соединений в секунду к одному сайту, тревога поднялась через 8 секунд. После блокировки его отпечатка последний запрос бота дошёл до сервера через секунду, а обычные посетители заходили на сайт как прежде.

Сайт на стенде, запросов в минуту До атаки Атака После правила
от бота 0 3 373 0
от обычных посетителей 38 34 43
Блок «Частота подключений»: пул default, отсечено правилами 5308; ниже «Кто шумит» с адресом бота
Отсечено правилом — 5 308 соединений, до сервера сайтов они не дошли

Панель не стоит на пути трафика, и это проверено переездом: её перенесли на новый сервер. Headscale не отвечал около полутора минут, панель — около 30 секунд, а сайты работали без перерыва: их трафик идёт мимо панели (по журналу проекта).

Комментарии

Писать можно, войдя через VK ID, через Яндекс ID или по коду из письма. У комментария — имя и фото из профиля VK или Яндекса либо имя, которое вы укажете при входе по почте. Как мы обращаемся с данными — в политике конфиденциальности.

Поделиться записью

TGVK