---
name: opera-proxy
description: >-
  Полный справочник по Opera Proxy в проекте zapret-gui (роутеры Keenetic на
  Entware / OpenWrt / Linux): standalone-клиент Opera VPN (SurfEasy), который
  поднимает локальный HTTP- или SOCKS5-прокси. Использовать при любых задачах
  о: CLI-флагах opera-proxy (-country/-bind-address/-socks-mode/-proxy-bypass/
  -fake-SNI/-verbosity/-list-countries/-bootstrap-dns/-server-selection/
  -api-proxy*/-timeout/-refresh/-proxy/-override-proxy-address/-config),
  цепочке подключения (bootstrap-DoH → api2.sec-tunnel.com → регистрация
  анонимного устройства → discover → CONNECT к eu0/as0/am0.sec-tunnel.com:443
  по TLS с Basic-авторизацией), регионах EU/AS/AM и `-list-countries` как
  сетевой операции, нашем менеджере и его pid-файле, дренаже stdout, буфере
  «Лог», валидации настроек (parse_bind/validate_settings), watchdog'е и
  TCP-пробе, статусе running/listening, установке бинарника «последним
  релизом» с sha256-политикой pinned/unpinned, API /api/opera-proxy/*, CLI
  `zapret-gui opera`, мониторинге трафика и диагностике «процесс жив, а порт
  не отвечает / страны не загружаются / прокси не проксирует». Источник
  истины — Alexey71/opera-proxy (сам бинарник и его `-h`), привязка — наш код
  core/opera_proxy_manager.py, core/opera_proxy_watchdog.py, api/opera_proxy.py,
  web/js/pages/opera_proxy.js, core/ext_binary_installer.py.
---

# Opera Proxy — справочник для zapret-gui

Единый источник истины о том, **как работает opera-proxy** и как с ним
обращаться в `zapret-gui`. Читать перед тем, как трогать менеджер, watchdog,
установку бинарника или объяснять «почему прокси не проксирует».

Источники истины (в порядке убывания авторитета):

1. **Сам бинарник.** `opera-proxy -h` — единственный достоверный список флагов
   и дефолтов для КОНКРЕТНОЙ версии; `opera-proxy -version` печатает ровно тег
   релиза (`v1.28.0`). Всё в §3 сверено с выводом `-h` реальной сборки
   `opera-proxy.linux-amd64` v1.28.0.
2. **[Alexey71/opera-proxy](https://github.com/Alexey71/opera-proxy)** (MIT) —
   исходники и релизы, откуда мы ставим бинарник. Апстрим релизится часто
   (раз в 1–2 недели), флаги между версиями добавляются.
3. **Наш код** — `core/opera_proxy_manager.py` (жизненный цикл, валидация,
   детект, страны, лог), `core/opera_proxy_watchdog.py` (проба и рестарт),
   `api/opera_proxy.py` (REST), `web/js/pages/opera_proxy.js` (страница),
   `core/ext_binary_installer.py` (`BINARIES["opera"]`), `core/cli.py`
   (`zapret-gui opera`), `core/config_manager.py` (секция `opera_proxy`),
   `app.py` (автозапуск при boot), `core/tunnel_monitor.py`,
   `core/update_checker.py`, `core/selfcheck.py`.

> ⚠️ **Главное, что путает при отладке:** живой процесс ≠ работающий прокси.
> opera-proxy **сначала** регистрируется в API SurfEasy и только потом
> открывает слушателя. Без доступа к API он бесконечно ретраится, процесс
> висит, порт закрыт. Поэтому статус отдаёт `listening` отдельно от
> `running` (§8), а причина всегда в «Логе» (§7).

---

## 1. Что это такое и чем НЕ является

**Что.** Standalone-клиент Opera VPN. Использует ту же инфраструктуру
**SurfEasy**, что встроенный VPN в браузере Opera: клиент сам регистрирует
анонимное устройство в API и получает доступ к прокси-узлам. Аккаунта,
подписки и своего сервера не нужно — отсюда роль «бесплатный запасной канал».

**Чем НЕ является:**

- **Не туннель и не метод единого слоя маршрутизации.** Это локальный
  HTTP/SOCKS5-прокси на порту. Он ничего не заворачивает прозрачно: трафик
  пойдёт через него, только если приложение/браузер настроены на этот порт
  либо кто-то снаружи (redsocks/tproxy) направил соединения туда.
  В `config_manager.routing.tunnel_priority` opera **намеренно отсутствует**, а
  `auto_remediation` его не выбирает — `UnifiedRoute(method="opera:…")` не
  существует, это закреплено тестом
  `tests/test_dev_merge_regressions.py::test_opera_not_in_default_priority`.
- **Не приватный VPN.** Инфраструктура чужая, вход анонимный и бесплатный:
  скорость/доступность не гарантированы, для чувствительного трафика — свой
  сервер (sing-box / AmneziaWG).
- **Не «настроил и забыл» на уровне протокола.** Логин обновляется каждые 4 ч
  (`-refresh`), при потере API-доступа прокси перестаёт работать.

---

## 2. Цепочка подключения (что происходит на самом деле)

```
opera-proxy -country EU -bind-address 127.0.0.1:18080
   │
   ├─1. bootstrap-DNS: резолв api2.sec-tunnel.com через ВСТРОЕННЫЙ список
   │     DoH/DoT-резолверов (НЕ через DNS роутера!) — см. -bootstrap-dns
   ├─2. POST https://api2.sec-tunnel.com/v4/register_subscriber
   │     — анонимная регистрация (login/password/client-type зашиты)
   ├─3. регистрация устройства → получение proxy-credentials
   ├─4. discover: список прокси-узлов для выбранной страны
   ├─5. server-selection (по умолчанию fastest → замер скачивания)
   └─6. ТОЛЬКО ТЕПЕРЬ открывается слушатель на bind-address
         │
         └─ клиентское соединение → CONNECT на <cc>0.sec-tunnel.com:443
              по TLS, Proxy-Authorization: Basic <device-creds>
```

Ключевые следствия, которые объясняют 90 % жалоб:

| Факт | Что из него следует |
|------|---------------------|
| Порт открывается **после** успешной инициализации | «Процесс есть, порт закрыт» — это не наш баг, а незавершённая регистрация |
| Резолв идёт через **DoH-пул**, а не системный DNS | Провайдер/роутер режет DoH (443 на резолверы) → клиент не стартует вовсе. Лечится `-bootstrap-dns dns://127.0.0.1:53` |
| `-init-retries` по умолчанию **0 = бесконечно**, интервал 5 с | Клиент не падает и не сдаётся: он будет ретраиться вечно. Именно поэтому наши таймауты обязаны быть жёсткими (§6) |
| Узлы — `eu0/as0/am0.sec-tunnel.com:443`, обычный TLS+CONNECT | Если DPI режет по SNI — помогает `-fake-SNI`; блок по IP узлов не лечится ничем из наших настроек |
| Логин обновляется каждые 4 ч | Долгоживущий процесс может «умереть» логически, оставаясь живым процессом → watchdog по TCP-пробе (§9) |

---

## 3. CLI-флаги (сверено с `-h` v1.28.0)

### 3.1. Те, что передаём мы

Порядок и условия — `OperaProxyManager.start()`:

```
opera-proxy -country <CC> -bind-address <host:port>
            [-socks-mode] [-proxy-bypass <list>] [-fake-SNI <domain>]
            -verbosity <N>
```

| Флаг | Дефолт бинарника | Что делает |
|------|------------------|------------|
| `-country` | `EU` | Регион. Для `-list-proxies-all` допускает список через запятую и `ALL` |
| `-bind-address` | `127.0.0.1:18080` | Адрес слушателя |
| `-socks-mode` | выкл | SOCKS вместо HTTP на том же порту |
| `-proxy-bypass` | пусто | Список через запятую: хосты/URL-паттерны мимо прокси, регистр не важен, поддерживает `*` в именах хостов |
| `-fake-SNI` | пусто | Домен, который подставляется как SNI в исходящий TLS **и в туннелируемый ClientHello, где это возможно** — то есть маскирует не только связь с SurfEasy |
| `-verbosity` | `20` | 10 debug, 20 info, 30 warning, 40 error, **50 critical**, 60 silent |

> В GUI-селекторе `verbosity` нет уровня 50 — это осознанно (шкала
> 10/20/30/40/60), но `validate_settings` пропускает любой int 0…60, так что
> 50 через API задать можно.

### 3.2. Служебные, которые вызываем отдельно

| Флаг | Где используем |
|------|----------------|
| `-version` | `_get_version()` — печатает ровно тег (`v1.28.0`), на этом держится сравнение «уже актуальная версия» в установщике и `has_update` в «Обновлениях» |
| `-list-countries` | `_fetch_countries()` — **сетевая операция**, см. §6 |

### 3.3. Что бинарник умеет, а GUI не показывает

Знать полезно: это готовые обходные пути, когда «не работает».

| Флаг | Зачем |
|------|-------|
| `-bootstrap-dns` | Список резолверов для поиска API. Дефолт — 9 публичных DoH (1.1.1.3, 8.8.8.8, dns.google, security.cloudflare-dns.com, dns.quad9.net, dns.adguard-dns.com, wikimedia-dns.org, doh.cleanbrowsing.org, fidelity.vm-0.com). Схемы: `dns://`, `https://`, `tls://`, `tcp://` |
| `-api-proxy`, `-api-proxy-file`, `-api-proxy-list-url`, `-api-proxy-parallel` | Достучаться до API SurfEasy **через сторонний прокси**, когда `api2.sec-tunnel.com` недоступен. Кандидаты перебираются по порядку |
| `-proxy` | Базовый прокси для ВСЕХ исходящих (`http/https/socks5/socks5h://[user:pass@]host:port`) |
| `-server-selection` | `first` / `random` / **`fastest`** (дефолт). `fastest` перед стартом качает тестовый файл с каждого узла — на слабом канале это заметная задержка старта; таймаут отбора — 1 мин |
| `-timeout` | Таймаут сетевых операций, дефолт **30s** |
| `-refresh`, `-refresh-retry` | Интервал обновления логина (4 ч) и повтора (5 с) |
| `-init-retries`, `-init-retry-interval` | Попытки инициализации (0 = бесконечно) и пауза (5 с) |
| `-override-proxy-address`, `-discover-csv`, `-list-proxies`, `-list-proxies-all[-out]`, `-proxy-blacklist`, `-sort-proxies-by`, `-estimate-proxy-speed` | Ручная работа со списком узлов в обход discover |
| `-cafile` | Свой CA-бандл |
| `-config` | Читать конфигурацию из файла «ключ значение» |
| `-api-login/-api-password/-api-client-type/-api-client-version/-api-user-agent` | Подмена зашитых учёток и «маскировки под Opera» |

> При добавлении нового флага в GUI: **сначала** проверь `-h` на той версии,
> которую реально ставит установщик. Флаг, которого нет, роняет старт целиком
> (Go печатает usage и выходит с кодом 2) — старт вернёт ошибку, а причина
> будет в буфере «Лог».

---

## 4. Наши настройки (`config_manager`, секция `opera_proxy`)

| Ключ | Дефолт | Комментарий |
|------|--------|-------------|
| `enabled` | `false` | «Opera должна работать». Выставляется при успешном `up` (API и CLI), снимается при `down`. **Гейт для boot-автозапуска и watchdog'а** |
| `autostart` | `false` | Подъём после перезагрузки **и** watchdog — одна галка в GUI |
| `country` | `EU` | EU / AS / AM (список стран см. §6) |
| `bind` | `127.0.0.1:18080` | `0.0.0.0:…` — отдать в LAN; IPv6 — `[::1]:…` |
| `socks_mode` | `false` | |
| `proxy_bypass` | `""` | |
| `fake_sni` | `""` | |
| `verbosity` | `20` | |
| `debug_log` | `false` | Глубина буфера «Лог»: 60 → 600 строк. Применяется **со следующего запуска** |
| `installed_tag` / `installed_arch` | `""` | Исторические ключи, сейчас никем не пишутся |

**Единый источник параметров запуска — `start_kwargs_from_config()`.** Все три
пути старта (API `up`, boot-автозапуск в `app.py`, рестарт watchdog'ом) и CLI
обязаны ходить через него. Раньше они расходились, и после перезагрузки прокси
поднимался без `fake_sni`/`proxy_bypass`/`verbosity`, а CLI вообще стартовал с
дефолтами — это чинилось дважды, не отменяй.

**Настройки применяются только при следующем запуске.** `PUT /config` живой
процесс не трогает: после «Сохранить» нужен «Остановить» → «Запустить».

---

## 5. Валидация (`parse_bind`, `validate_settings`)

Обе функции — в `core/opera_proxy_manager.py`, и они **общие** для API, старта,
watchdog-пробы и монитора. Не заводи локальный разбор адреса.

`parse_bind(bind) -> (host, port)` понимает `127.0.0.1:18080`, `localhost:8080`,
`[::1]:18080`, `[::]:18080`; кидает `ValueError` с человекочитаемой причиной на
пустом адресе, адресе без порта, порте вне 1…65535 и на голом IPv6 без скобок.

`validate_settings(dict) -> dict` нормализует только переданные ключи (частичный
PUT): страна → upper и `isalnum`, bind → канонизируется через `parse_bind`,
bool-поля принимают `true/1/yes/on`, `proxy_bypass` — пробелы вокруг элементов
вычищаются (а пробел внутри записи это ошибка), `fake_sni` — только
`[A-Za-z0-9.-_]`, `verbosity` — целое 0…60. Ошибка → `ValueError`, API
отвечает **HTTP 400** с текстом.

`start()` прогоняет ту же валидацию перед `Popen`: в `settings.json` мог остаться
мусор с тех времён, когда валидации не было.

---

## 6. Страны: почему это дорого и как устроен кэш

`-list-countries` — **не локальная команда**. Бинарник ради неё проходит шаги
1–4 из §2, то есть **регистрирует новое устройство** в API SurfEasy.

Поэтому:

- `detect()` **никогда** не вызывает `-list-countries` сам. Он дешёвый: версия
  берётся из кэша по `(path, mtime, size)`, страны — из памяти.
- Реальный запрос делает только `list_countries(refresh=True)` →
  `GET /api/opera-proxy/countries?refresh=1` (кнопка «Обновить список стран»).
- Анти-дребезг `_COUNTRIES_MIN_REFRESH_SEC = 60` + неблокирующий лок: два клика
  или две вкладки не породят две регистрации.
- Таймаут `_COUNTRIES_TIMEOUT = 30` секунд. Он обязан быть, потому что при
  недоступном API бинарник ретраится **бесконечно** (§2). Истёк → отдаём
  «opera-proxy не ответил за 30s — нет доступа к API SurfEasy?».
- Формат вывода — CSV с заголовком `country code,country name`; парсер
  пропускает строки, начинающиеся с `country`.

> Историческая ошибка, которую нельзя повторить: `detect()` вызывался из
> общего поллинга страницы (раз в 3 с) — получалась регистрация устройства
> каждые три секунды плюс два форка на тик на роутерном CPU. Поллинг GUI
> тянет **только** `/status`; `detect` и `config` — по открытию страницы и
> по кнопке «Обновить».

Базовые регионы: `EU` (Europe), `AS` (Asia), `AM` (Americas).

---

## 7. Менеджер: процесс, pid-файл, лог

`core/opera_proxy_manager.py`, singleton `get_opera_proxy_manager()`.

**Поиск бинарника** (`_find_binary`, первый существующий и исполняемый):
`/opt/usr/bin/opera-proxy` → `/opt/bin/opera-proxy` →
`/usr/local/bin/opera-proxy` → `/usr/bin/opera-proxy`.

**Старт.** `Popen(..., stdout=PIPE, stderr=STDOUT, start_new_session=True)`,
затем `sleep(1)` и проверка `poll()`: мгновенные падения — это занятый порт и
негодные аргументы. PID пишется в `<config_dir>/opera-proxy.pid`.

**Дренаж stdout — обязателен.** Поток `opera-proxy-drain` непрерывно читает
пайп в кольцевой буфер. Без него OS-буфер (~64 КБ) переполняется, Go-шный
логгер блокируется на `write()` **вместе с обработчиком соединения**, и прокси
перестаёт форвардить трафик. Это не теория: регресс-тест
`TestProxying::test_traffic_survives_chatty_logging` гоняет 512 КБ через
CONNECT при `-verbosity 10` и падает по таймауту, если дренаж убрать.
Буфер заводится **до** `Popen`, поэтому в «Логе» видна и командная строка, и
причина неудачного старта.

**Стоп.** Есть объект процесса → `SIGTERM`, `wait(3)`, иначе `kill`. Объекта нет
(GUI перезапускали) → гасим по pid-файлу с проверкой `_pid_is_opera()`
(читаем `/proc/<pid>/cmdline`; файл лежит в `/opt` и переживает перезагрузку,
так что чужой PID вполне возможен), затем `SIGTERM` → до 3 с → `SIGKILL`.

**Лог.** `read_log(lines)` → `{ok, debug, captured, log}`; глубина 60 строк,
600 в режиме отладки (`opera_proxy.debug_log`), каждая строка обрезается до
400 символов. Подробность строк задаёт `-verbosity` самого бинарника, наш
режим отладки влияет только на глубину.

---

## 8. Статус: `running` ≠ `listening`

`status(probe=True)` возвращает:

```json
{"running": true, "pid": 1234, "bind": "127.0.0.1:18080", "listening": false}
```

- `running` — жив ли процесс (объект в памяти или pid-файл + `/proc`).
- `bind` — **фактический** адрес запущенного процесса (`_running_bind`), после
  перезапуска GUI — из конфига.
- `listening` — TCP-проба, есть только когда `running`. Именно это отличает
  «работает» от «висит в ретраях регистрации» (§2).

GUI пишет «Запущен, но порт не отвечает» и отправляет в «Лог»; CLI —
`bind: … (порт не отвечает)`.

---

## 9. Watchdog

`core/opera_proxy_watchdog.py`. Гейт: **`enabled` И `autostart`** — обе
проверяются и в `reconfigure()`, и на каждом тике (конфиг могли поменять из
другой вкладки).

Константы: интервал 60 с, порог 3 подряд, cooldown 120 с, не больше 6
рестартов в час.

`probe_proxy(bind, timeout)` — общая TCP-проба (её же использует `status()` и
`tunnel_monitor`): разбирает адрес через `parse_bind`, ходит через
`socket.create_connection` (то есть IPv6 работает), wildcard заменяет на
loopback (`0.0.0.0` → `127.0.0.1`, `::` → `::1`).

Тик:

1. `enabled`/`autostart` сняты → watchdog сам себя останавливает.
2. Процесс мёртв → счётчик, на третий раз рестарт.
3. Bind не парсится → **проба пропускается**, счётчик сбрасывается. Иначе был
   бы вечный цикл «проба провалилась → рестарт → прокси не стартует».
4. Проба прошла → счётчик в ноль; не прошла → как п.2.

Рестарт = `stop()` + `start(**start_kwargs_from_config())`, то есть watchdog
всегда приводит прокси к тому, что записано в конфиге.

---

## 10. Установка бинарника

`core/ext_binary_installer.py`, `BINARIES["opera"]`.

```python
"repo": "Alexey71/opera-proxy",
"release_tag": "",          # пусто = /releases/latest
"pinned_tag": "v1.28.0",    # версия, для которой известны sha256
"allow_unpinned": True,
"dest": "/opt/usr/bin/opera-proxy",
"arch_map": {"aarch64": "opera-proxy.linux-arm64",
             "x86_64":  "opera-proxy.linux-amd64",
             "mipsel":  "opera-proxy.linux-mipsle",
             "mips":    "opera-proxy.linux-mips",
             "armv7":   "opera-proxy.linux-arm"},
```

**Ставим последний релиз.** Закреплённый тег давал тупик: «Обновления» видят
новую версию апстрима, а кнопка «Установить» молча ставит старую. Проверка
«уже актуально» работает благодаря тому, что `-version` печатает ровно тег.

**Политика sha256** (общая для всех `allow_unpinned`-бинарников):

| Ситуация | Поведение |
|----------|-----------|
| Тег == `pinned_tag`, хэш для арх. есть | Сверка, несовпадение → `InstallError`, установка прервана |
| Тег == `pinned_tag`, хэша для арх. НЕТ | **Отказ**: это дыра в манифесте, а не «версия новее» |
| Тег новее, у релиза есть файл контрольных сумм | Сверяемся с ним (`_verify_downloaded_file`) |
| Тег новее, контрольных сумм нет | Ставим, но `sha256_verified: false` + предупреждение в лог и тост в GUI |

Апстрим сейчас **не публикует** checksums (`checksums.txt`, `SHA256SUMS`,
`*.sha256` — 404), так что на практике для версий новее `pinned_tag` работает
последняя строка. Поднимая `pinned_tag`, обязательно пересчитай sha256 всех
четырёх сборок с релизных URL и проверь процедуру на предыдущей версии (хэши
старого тега должны совпасть с тем, что уже лежит в манифесте).

**Архитектуры.** Апстрим публикует ~29 ассетов (linux/darwin/freebsd/android,
включая `linux-arm` и `linux-386`); мы маппим пять, которые встречаются на
роутерах: `aarch64`, `x86_64`, `mipsel`, `mips`, `armv7` (`opera-proxy.linux-arm`,
ELF ARM EABI5). Сборки под **riscv64 нет** (404) — на ней установка честно
отказывает «Архитектура … не поддерживается». Прежде чем добавлять новую
архитектуру в `arch_map`, проверь, что ассет реально существует в релизе
(`curl -o /dev/null -w '%{http_code}' -L <release-url>/<asset>`), и посчитай
его sha256.

---

## 11. API (`api/opera_proxy.py`)

| Метод | Путь | Что делает |
|-------|------|------------|
| GET | `/api/opera-proxy/status` | `running`, `pid`, `bind`, `listening` |
| GET | `/api/opera-proxy/detect` | Дёшево: `installed`, `binary`, `version`, страны из кэша |
| GET | `/api/opera-proxy/countries[?refresh=1]` | Без `refresh` — кэш; с `refresh` — **сетевой запрос** (§6) |
| POST | `/api/opera-proxy/up` | Конфиг + валидированные overrides из тела; при успехе `enabled=true` и `reconfigure()` watchdog'а |
| POST | `/api/opera-proxy/down` | `enabled=false`, стоп, `reconfigure()` |
| GET/PUT | `/api/opera-proxy/config` | Настройки; PUT валидирует (HTTP 400 с текстом) |
| GET/POST | `/api/opera-proxy/debug` | Флаг `debug_log` |
| GET | `/api/opera-proxy/log?lines=N` | Хвост вывода |
| POST | `/api/opera-proxy/install` \| `/uninstall` | `install_binary_by_name("opera")` / `uninstall_binary("opera")` |

Тело запроса читается через `_body()` — кривой JSON даёт `{}`, а не HTTP 500.

**CLI:** `zapret-gui opera {status|start|stop}` — тот же путь, что у GUI
(настройки из конфига, выставление `enabled`).

**Прочие потребители:** `/api/dashboard/status` (ключ `opera`),
`core/selfcheck.py` (наличие и версия), `core/update_checker.py::_check_opera`
(сравнение с `_github_latest`), `core/tunnel_monitor.py::_read_opera_stats`
(порт берётся **из настроек**, не хардкод; метрики эмулируются из числа
ESTABLISHED-соединений).

---

## 12. Диагностика «не работает»

1. **Бинарник есть?** `/api/opera-proxy/detect` → `installed`, `version`.
   Кнопка «Запустить» в GUI гасится, пока детект не подтвердит наличие.
2. **Процесс поднялся?** `status.running`. Нет → смотри `error` от `up` и
   «Лог»: там командная строка и причина (занятый порт, негодный флаг).
3. **Порт отвечает?** `status.listening`. `running && !listening` — почти
   всегда незавершённая инициализация:
   - в «Логе» строки `Attempting action "anonymous registration"` /
     `Action … failed` — API недоступен;
   - в ошибке видны `lookup api2.sec-tunnel.com … dns-query …` — не работает
     **bootstrap-DoH** (провайдер режет DoH, перехват DNS, MITM-сертификат).
     Обход — `-bootstrap-dns dns://<локальный резолвер>` (в GUI не вынесено);
   - `dial tcp … i/o timeout` на `*.sec-tunnel.com` — блок самой
     инфраструктуры SurfEasy; помогает только внешний обход
     (`-api-proxy`/`-proxy`) или другой канал.
4. **Порт слушает, но трафик не идёт?**
   - проверь, что клиент настроен на нужный режим: `socks_mode` меняет
     протокол на том же порту — HTTP-клиент в SOCKS-порт не пойдёт;
   - `bind=127.0.0.1` виден только самому роутеру: с ноутбука не подключиться,
     нужен `0.0.0.0`;
   - `proxy_bypass` мог увести нужный домен напрямую.
5. **Прокси «зависает» под нагрузкой.** Первым делом проверь, что дренаж
   stdout жив (§7) — это классика для `verbosity ≤ 20`.
6. **Watchdog дёргает исправный прокси.** Смотри `bind`: проба ходит по
   адресу из конфига, а процесс мог быть запущен с другим (override в `up`).
7. **Страны не загружаются.** Это отдельная сетевая операция (§6); её отказ
   **не** мешает прокси работать — регион задаётся кодом, а не списком.
8. **«Обновления» показывают новую версию.** Кнопка «Обновить до последней
   версии» на странице; после замены файла работающий процесс остаётся на
   старой версии — нужен перезапуск.

---

## 13. Инварианты — что не ломать

1. **`detect()` дешёвый.** Никаких сетевых вызовов: его дёргают поллинг GUI,
   selfcheck и update-checker.
2. **Дренаж stdout не отключать** и не заменять на «читать по запросу».
3. **Один источник параметров запуска** — `start_kwargs_from_config()`.
4. **Один разбор адреса** — `parse_bind()`; проба — только `probe_proxy()`.
5. **`enabled` выставляют все пути** старта/остановки (API, CLI), иначе
   boot-автозапуск и watchdog молча мертвы.
6. **Валидация до записи в конфиг**, а не при старте: иначе мусор оседает в
   `settings.json` и всплывает usage-дампом Go-бинарника.
7. **Форма настроек не перерисовывается по таймеру** (затирает ввод) — рендер
   один раз, как в `usque.js`.
8. **opera не добавлять в `tunnel_priority` / unified-методы** — это не
   прозрачный метод маршрутизации (§1).
9. **Флаги сверять с `-h` целевой версии** перед добавлением в GUI.

Регресс-тесты: `tests/test_opera_proxy.py` (менеджер, watchdog, API, CLI,
монитор + сквозной прогон трафика через CONNECT-прокси),
`tests/test_ext_binary_installer.py::TestOperaLatestRelease` (политика
latest/pinned/unpinned).
