Кейс · Скорость и инфраструктура, Веб и SaaS
EdgeShift своя замена прокси Cloudflare
Панель и агенты, которые прячут настоящий сервер сайтов за промежуточными серверами — фронтами — и меняют публичный адрес, когда фронт атакуют. Трафик фронт не расшифровывает: ключи TLS остаются у владельца сайтов.
Паспорт проекта
- Отрасль
- Хостинг сайтов
- Срок
- 2,5 недели
- Команда
- Команда E404 и ИИ-агент
Стек

Задача
У владельца — сервер с несколькими десятками сайтов на WordPress и MODX: nginx и PHP 8.4. DNS — в Cloudflare, но у части сайтов записи вели прямо на сервер, без прокси. Поэтому настоящий адрес сервера виден любому, кто спросит DNS, и атака на любой из этих сайтов приходит прямо на сервер — и задевает все сайты сразу.
Нужна была своя замена ровно одной функции Cloudflare. Посетители видят только адрес промежуточного сервера — фронта. Настоящий сервер отвечает через туннель. Когда фронт атакуют, его адрес в DNS меняется на резервный.
Ограничения были такие:
- Живые сайты. Ни один наш шаг не должен их уронить — даже на минуту.
- Ключи остаются у владельца. Фронт не расшифровывает TLS, поэтому видит только адрес клиента, имя сайта и то, как устанавливается соединение.
- Разные хостеры. Общего API у провайдеров фронтов нет — общий у них только SSH.
- Одна панель на всех заказчиков. Каждый видит только своё.
Все экраны в кейсе — со стенда с выдуманными данными: веб-студия «Кедр», сайты на .example, адреса из диапазонов для документации. Стенд собран из кода проекта, версия 1.21.1. Боевую панель для кейса не снимали.

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


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


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



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

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

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

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

Как устроено

Панель, агент фронта, агент сервера сайтов и сервер 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. Расхождение заметили глазами: карточка установки агента наезжала сама на себя.


Все 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 |

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



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