Improve failover controls and preserve manual switching state
Build and Deploy Gateway / build-and-push (push) Failing after 1s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-28 19:40:53 +03:00
parent 8f2f418569
commit a84cca0668
30 changed files with 547 additions and 112 deletions
+209
View File
@@ -0,0 +1,209 @@
# Read-only аудит failover/failback
## Короткий вывод
Штатное переключение между `primary` и `reserve` меняет маршрут **только для новых соединений**. Уже установленные TCP/UDP-соединения не переносятся и не закрываются самим Harbor.
Поэтому:
- здоровая загрузка, игра или поток продолжаются через старый канал;
- если старый канал действительно умер, существующая сессия может оборваться независимо от переключения;
- после переключения новые соединения идут через новый канал;
- бесшовной миграции уже открытого TCP/UDP-сеанса на другой внешний адрес нет.
## 1. Точный механизм
### Конфигурация
Gateway собирает один dual-channel `sing-box` config:
- `channel-primary`;
- `channel-reserve`;
- selector `channel-selector`;
- стабильные `tproxy-in` и `mixed-in`;
- отдельные diagnostic inbounds для проверки каждого канала.
Пользовательский трафик направляется в selector, а selector настроен с:
```text
interrupt_exist_connections: false
```
См. `src/server/singbox.ts:204-247`.
Роль меняется через localhost Clash API:
- `PUT /proxies/channel-selector`;
- затем Harbor читает selector обратно и подтверждает выбранную роль.
См. `src/server/services/singboxSelectorService.ts:29-81`.
Failover API доступен только в Gateway:
- `PUT /api/failover`;
- `POST /api/failover/pause`;
- `POST /api/failover/switch`;
- `POST /api/failover/check`.
См. `src/server/http/routes/failoverRoute.ts:18-39`.
Переключение selector не вызывает `sing-box` restart/apply/stop.
### Автоматический failover
`FailoverService`:
1. Проверяет оба канала через отдельные diagnostic inbound и выбранные HTTPS-сервисы.
2. Считает канал healthy только если все проверки успешны; неизвестный результат даёт `unknown`.
3. При сбое primary ждёт `failureWindowMs`.
4. Переключается на reserve только если reserve healthy.
5. При включённом traffic guard ждёт свежий quiet-window.
6. Перед самой сменой повторно проверяет здоровье и активность.
7. Выполняет selector PUT, read-back и только после этого обновляет canonical applied state.
См. `src/server/features/failover/failoverService.ts:225-245`, `:320-514`; state machine — `src/shared/failover.ts:278-354`.
Дефолты:
- проверка каждые 60 секунд;
- сбой primary — 120 секунд;
- восстановление primary — 15 минут;
- quiet-window — 30 секунд;
- активный трафик — выше 32 КБ/с;
- минимум на reserve — 10 минут;
- после 3 failover за 24 часа — карантин primary на 6 часов.
См. `src/shared/failover.ts:137-148`.
### Автоматический failback
Отдельной реализации нет: это обратная ветка той же state machine.
Из reserve Harbor возвращается на primary только после:
- полного `recoveryWindowMs`;
- `minimumReserveMs`;
- окончания quarantine, если он действует;
- quiet-window при включённом traffic guard;
- подтверждения, что primary healthy.
Причина переключения публикуется как `primary-recovered`. См. `src/shared/failover.ts:311-354`.
## 2. Судьба существующих соединений
| Событие | Уже открытый TCP/UDP flow | Новые соединения |
|---|---|---|
| Автоматический failover primary → reserve | Остаётся на прежнем outbound; Harbor его не закрывает и не мигрирует | Идут через reserve |
| Автоматический failback reserve → primary | Остаётся на reserve | Идут через primary |
| Ручной selector switch | Не закрывается, если старый outbound ещё работает | Сразу идёт через выбранную роль |
| Pause | Ничего не меняет | Идут через текущую роль |
| Disable failover | Selector и текущий маршрут не меняются; dual config временно остаётся загруженным | Идут через текущую роль |
| Обычный stop/restart/config replacement | Процесс `sing-box` останавливается, поэтому TCP/UDP-сессии прерываются | После запуска — по новой конфигурации |
| Реальная авария primary | Уже существующий flow может оборваться сам; Harbor не может перенести его на другой внешний IP | После selector switch новые flow идут через reserve |
Проверка `interrupt_exist_connections: false` непосредственно подтверждена TCP- и UDP-fixture-тестом: существующие TCP socket и UDP association продолжают обмен после switch, а новые идут через reserve. См. `test/server/singbox-selector-capability.test.js:141-142`, `:237-356`.
## 3. Отличия режимов
### Ручное переключение
`POST /api/failover/switch` вызывает selector напрямую:
- traffic guard не проверяется;
- состояние здоровья целевого канала backend не проверяет;
- после успешного ручного переключения `failoverPolicy.paused` становится `true`;
- автоматический failback не произойдёт, пока пользователь не возобновит автоматическое переключение.
См. `src/server/features/failover/failoverService.ts:248-317`, `:614-631`.
Это означает, что ручной switch может быть выполнен даже на канал, который сейчас не подтверждён healthy. Это важная оговорка.
### Автоматический failover
Автоматическое переключение:
- ждёт failure window;
- требует healthy reserve;
- при включённом guard блокируется активным или неизвестным трафиком;
- повторно валидирует условия непосредственно перед selector mutation;
- не перезапускает `sing-box`.
### Восстановление primary
Восстановившийся primary не получает новые соединения сразу. Сначала выдерживаются recovery/hold/quarantine условия и quiet-window. Пока они не выполнены, новые подключения остаются на reserve.
### Pause и Disable
`pause` приостанавливает решения, но dual config и наблюдение остаются активными.
`disable` останавливает scheduler, probes и failover activity collector, но не переключает selector и не перезапускает процесс. После обычного stop/следующего запуска собирается single-channel config.
См. `README.md:78-84`, `docs/product/application-state.md:102-110`.
## 4. Практические сценарии
- **Загрузка файла:** при обычном автоматическом failover активная передача по умолчанию задерживает switch. Если primary всё же упал, текущая TCP-загрузка не переносится на reserve; она может завершиться ошибкой. Возобновление или новый HTTP-запрос после switch пойдёт через reserve.
- **Игровая сессия:** существующий TCP-сеанс остаётся на старом канале. TCP-сессия оборвётся, если primary реально недоступен. UDP-flow также не мигрирует и может начать терять пакеты или истечь по timeout; новая сессия после switch пойдёт через reserve.
- **Стрим:** активный поток обычно блокирует автоматический switch при включённом guard. Ручной switch может быть выполнен сразу, но существующий TCP/QUIC-поток остаётся на старом outbound. При аварии старого канала плеер должен переподключиться.
- **WebSocket/долгий polling:** действующий flow не переносится; новые подключения после switch используют новую роль.
- **Молчащее соединение:** наличие открытого socket само по себе не считается активностью. Guard смотрит на дельты переданных байтов.
## 5. Условия и оговорки
- Failover реализован только для **Gateway**, не для локального Connect или `gateway-direct`.
- Активность собирается существующим `/connections` observer каждые 2 секунды, с bounded окном до 10 секунд. См. `src/server/index.ts:311-333`, `src/server/services/domainTrafficService.ts:323-416`, `:437-495`.
- Короткое соединение, полностью завершившееся между двумя снимками, может не попасть в activity guard.
- Неизвестная или устаревшая activity-информация блокирует автоматический switch; ручной switch остаётся доступен.
- Отключение traffic guard разрешает автоматический switch без ожидания тишины, но `interrupt_exist_connections: false` всё равно защищает уже открытые connections от закрытия самим selector.
- При изменении policy во время работающего single-channel VPN dual config становится `pending`; скрытого restart нет. См. `src/server/features/connection/connectionService.ts:140-201`, `:288-361`.
- Явный stop/restart или обычная смена сервера вне активного failover уже является disruptive operation: `sing-box` получает SIGTERM и текущие сессии прекращаются. См. `src/server/singboxRuntime.ts:39-62`, `:65-122`.
- Оба канала находятся в одном процессе `sing-box`; process-wide crash не защищён selector-механизмом.
## 6. Основные файлы
- `src/server/singbox.ts:204-247` — dual config, selector, inbound routing.
- `src/server/services/singboxSelectorService.ts:29-81` — selector PUT/read-back.
- `src/server/features/failover/failoverService.ts:225-245` — проверки каналов.
- `src/server/features/failover/failoverService.ts:248-317` — selector switch, commit и rollback.
- `src/server/features/failover/failoverService.ts:320-514` — автоматический раунд и traffic guard.
- `src/server/features/failover/failoverService.ts:614-631` — ручное переключение.
- `src/shared/failover.ts:137-148`, `:278-354` — дефолты и state machine.
- `src/server/services/domainTrafficService.ts:129-160`, `:323-416`, `:437-495` — классификация и activity.
- `src/server/features/connection/connectionService.ts:140-201`, `:243-361` — обычный apply/stop/restart.
- `src/server/singboxRuntime.ts:39-122` — фактическая остановка и перезапуск процесса.
- `README.md:78-84` — пользовательская документация.
- `docs/product/application-state.md:102-110` — контракт состояния.
- `workpack/tasks/TASK-021-auto-server-selection-failover.md:93-106`, `:187-204` — относящийся план и ограничения; задача не выбиралась и не изменялась.
## 7. Тесты, подтверждающие выводы
- `test/server/singbox-selector-capability.test.js:141-356`
Интеграционная TCP/UDP-проверка: активный трафик задерживает failover, существующие TCP/UDP продолжают работать после switch, новые соединения идут через reserve, PID процесса не меняется. Тест opt-in и пропускается без `HARBOR_SINGBOX_IMAGE`.
- `test/server/singbox-gateway-mode.test.js:69-110`
Проверяет selector, `interrupt_exist_connections: false`, отдельные diagnostic routes и primary/reserve outbounds.
- `test/server/failover-service.test.js:83-200`
Failure window, traffic guard, both-unhealthy и failback recovery/hold/quarantine.
- `test/server/failover-service.test.js:255-420`
Disabled zero-work, отмена устаревших наблюдений и непосредственная revalidation активности.
- `test/server/failover-service.test.js:423-520`
Selector rollback, commit ordering и ручной switch с pause.
- `test/server/domain-traffic.test.js:209-239`
Activity считается по пользовательскому VPN traffic, diagnostic connection не блокирует switch.
- `test/server/connection-service.test.js:265-310`, `:340-390`
Restart dual config и rollback; pending edits для работающего single-channel.
- `test/server/singbox-runtime.test.js:16-50`
При изменении config runtime запускает новый процесс.
- `test/server/failover-route.test.js:10-53`
Gateway-only API и маршруты ручного switch/pause/check.
- `test/web/failover-feature-contract.test.js:19-76`
UI явно сообщает: новые подключения переключаются, открытые остаются на прежнем канале.
## Review findings и residual risks
- **medium — `src/server/features/failover/failoverService.ts:614-620`:** ручной switch не проверяет health целевого канала и не применяет traffic guard; он сразу меняет selector и ставит automation на pause.
- **medium — `test/server/singbox-selector-capability.test.js:141-356`:** capability test opt-in и использует deterministic `direct` outbounds, а не реальный VLESS/Trojan outage.
- **medium — `src/server/services/domainTrafficService.ts:414-416`, `:451-479`:** activity основана на polling и byte deltas; короткие или очень малые потоки могут не блокировать автоматическое решение.
- **info — `src/server/singboxRuntime.ts:39-122`:** явный stop/restart отличается от selector switch и прерывает существующие соединения.
- **info — архитектура одного процесса:** падение всего `sing-box` не компенсируется selector failover.
Файлы не изменялись. Тесты и live Gateway в рамках read-only аудита не запускались.