feat(notify): unified notification manager + webhooks + shell bell

Ships an end-to-end notification pipeline: ring buffer + persistence,
HTTP CRUD + SSE stream, backend Push() calls at ~20 async/scheduled
sites, Telegram+generic HTTP dispatcher, shell bell icon with sliding
panel, and a Notifications settings section for webhook management.

Backend (Agents U + V):
- New pkg internal/services/notify — cap-500 ring, SSE broadcast via
  events.DefaultHub topic "notifications", MarkRead / MarkAllRead /
  Delete / Clear + persistence to <dataDir>/notifications.json with
  500 ms debounced atomic write.
- Webhook dispatcher: goroutine consumes notify.PushChan, fan-out to
  every matching hook. Filters all / errors_only / warn_and_above /
  success_and_above. Retry 3× with 1/2/4 s backoff, 10 s per-request
  timeout. Telegram POST {chat_id, text=title+body, parse_mode:HTML};
  generic POST raw Notification JSON. LastError / LastSuccessAt
  stamped per attempt.
- 11 Push sites: feeds.fetch / feeds.error, routing.apply / commit /
  rollback, awg.tunnel_up / tunnel_down / handshake_recovered,
  update.available / applied / failed, pihole.enabled / disabled /
  container_restarted, auth.login / auth.failed (now with client IP
  + User-Agent via new AuthClient meta), backup.import / export,
  panic.recovered (server.go recoverer middleware), ipmeta.reload.

Frontend (Agents W + X):
- lib/notifications.ts — module-level singleton store,
  useSyncExternalStore hook, refcounted SSE + initial GET, backoff+
  jitter reconnect, optimistic UI actions, sonner-toast fallback for
  incoming errors when the panel is closed.
- NotifyBell + NotifyPanel — bell now lives in the desktop sidebar
  HEADER next to the "NFQWS2" title (was previously footer-only per
  user request), still mounted in mobile top bar. Sheet panel groups
  by Today / Yesterday / Older, filter chips (All / Errors /
  Warnings / Unread), row = colored dot + title (bold if unread) +
  2-line body + relative timestamp (Intl.RelativeTimeFormat) + source
  badge + hover dismiss X. Panel header pr-12 so the sheet's built-in
  close X no longer collides with "Clear all".
- Notifications settings under System — WebhookTable + Add/Edit
  Dialog with kind-dependent labels (Telegram bot-token URL + chat_id
  vs generic URL), inline validation, Test button hitting the backend
  test endpoint.
- All strings routed through dict.ts (en+ru) — 60+ new keys under
  notify.*.

Wiring in server.go / app.go: RegisterNotifyAPI +
RegisterNotifyWebhooksAPI + /api/notifications/stream SSE alias +
notify.InitDispatcher on boot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/168/head
h8suga 2 months ago
parent ea5280a24c
commit f9dd4d9f9b

BIN
.DS_Store vendored

Binary file not shown.

2
0

@ -0,0 +1,2 @@
Warning: Permanently added '192.168.31.1' (RSA) to the list of known hosts.
snitch PID: 14759 14755 (no - shell script wrapper)

@ -0,0 +1,183 @@
# AWG2 selector — xray-rules + per-device roadmap
Цель: в AWG2-вкладке селектора `nfqws2-keenetic-strategy-selector` поддержать
формат правил Xray (для импорта существующих конфигов) **и** per-source-device
маршрутизацию, при этом туннелем остаётся AmneziaWG (amneziawg-go), не Xray.
Все изменения — в форке `feat/awg2` (или ветке от него), backend на Go,
UI правится в `internal/server/web/index.html` (vite single-file build).
## Phase 0 — per-source-device в zones
| Файл | Что |
|---|---|
| `internal/services/awg/config.go` | `Zone` → новое поле `SourceIPs []string `json:"source_ips"`` (опционально, пусто = вся сеть) |
| `internal/services/awgroute/firewall_linux.go` | при рендере mangle-правил: если `SourceIPs` непуст → для каждого IP `-s <ip>` перед `-j MARK` |
| `internal/services/awgroute/mode.go``mode_test.go`) | покрыть тестом: одна зона с SourceIPs матчит только нужный IP |
| UI: `internal/server/web/index.html` | в форме зоны под доменами добавить поле «Источники (IP/CIDR)» + помощь «оставь пустым → для всех» |
**Готов критерий**: одна зона с `source_ips: ["192.168.31.243"]` и `*` в доменах
гонит ровно `.243` через awg0, остальные устройства идут напрямую.
## Phase 1 — geosite в zones + автообновление
### 1a. Geo доступен в awgroute
| Файл | Что |
|---|---|
| `internal/tools/geo/geo.go` | публичный метод `Enumerate(kind, category string) ([]string, error)` |
| `internal/app/app.go` | передать `*geo.Geo` в `awgroute.New(...)` |
| `internal/services/awgroute/awgroute.go` | хранить `geo *geo.Geo`, использовать на резолве |
### 1b. Префиксы в zone-парсере
В существующем поле «домены» зоны строки `geosite:CATEGORY`, `domain:foo.bar`,
`full:exact.bar` распознаются:
- `domain:foo.bar` → суффиксная маска (как `*.foo.bar`)
- `full:foo.bar` → ровный матч
- `geosite:CN` → разворачивается через `geo.Enumerate("geosite","cn")` на лету
при `Apply()`. Если geosite.dat ещё не загружен → ошибка в `Apply`-ответ
«загрузите geosite.dat или включи автообновление».
- `geoip:CN` → раскрывается в CIDR-список в существующее поле IPs.
- `list:NAME` → читает `/opt/etc/nfqws2/lists/NAME.list` (те же файлы что nfqws2
использует для hostlist/ipset DPI-фильтров: `user`, `exclude`, `auto`,
`ipset`, `ipset_exclude`). Распознавание IPs vs доменов по `isIPish()`.
| Файл | Что |
|---|---|
| `internal/services/awgroute/sets_linux.go` (или где идёт `range zone.Domains`) | новый helper `expandDomains(zone, geo) (plain []string, ips []string, err error)` |
| `internal/services/awg/match.go` | парсер префиксов |
### 1c. Фоновый фетчер
| Файл | Что |
|---|---|
| `internal/services/awgroute/awgroute.go` | в `New(...)` стартует goroutine с тикером (по умолчанию 24h, выкл если URL пуст) |
| `internal/services/awgroute/geo_fetch.go` (новый) | `func fetchGeo(ctx, url, kind) ([]byte, error)`, кладёт в существующий `Geo` store через `geo.Upload(...)` |
| `internal/services/awg/config.go` | новый блок `GeoUpdate { Enabled bool; GeositeURL, GeoipURL string; IntervalHours int; LastFetchedAt int64; LastError string }` в `ServerConfig` |
Дефолт URL: `https://github.com/v2fly/domain-list-community/releases/latest/download/geosite.dat`
и `https://github.com/v2fly/geoip/releases/latest/download/geoip.dat`.
### 1d. UI: панель «Geo»
В `index.html` (vite single-file, придётся `npm --prefix frontend run build`):
- В Routing tab над списком зон секция «GeoSite / GeoIP»:
- текущая версия + `last_fetched_at`
- две URL-поля (geosite/geoip), интервал в часах, тогл «авто»
- кнопка «Обновить сейчас» → `POST /api/geo/fetch-now`
- последняя ошибка (если была)
| Файл | Что |
|---|---|
| `internal/server/server.go` | новый `POST /api/geo/fetch-now`, ручка зовёт fetcher синхронно |
| `frontend/src/...` | компонент GeoPanel |
| `scripts/build.sh` | без изменений (vite-build уже есть) |
## Phase 2 — Xray JSON importer
### 2a. Endpoint
`POST /api/awg2/routing/import-xray` принимает:
```json
{"json": "<xray routing config text>", "dry_run": true}
```
Парсит `routing.rules`:
- `outboundTag: "proxy"` → Include-зона (в туннель)
- `outboundTag: "direct"` → Exclude-зона (мимо туннеля)
- `outboundTag: "block"` → в новую отдельную Drop-зону (опционально; иначе игнор с предупреждением)
Каждое правило раскидывается в `domains`/`ips` по типу:
- `domain` → строки `domain:`/`geosite:`/`regexp:` сохраняются с префиксом, парсер фазы 1 их расширит
- `ip` → CIDR в `ips`, `geoip:CN` сохраняется с префиксом
- `port`/`protocol`/`source`/`inboundTag` → не поддерживаем, в ответе
`dropped: [{rule_index, reason}]`
### 2b. Конвертер regexp → wildcards
`internal/services/awg/xray_regex.go` (новый):
- `(^|\.)foo\.(com|ru)$``*.foo.com`, `*.foo.ru`
- `(^|\.)foo[a-z0-9-]*\.com$``*.foo*.com` (если поддержим), иначе сохранить как `regexp:` для фазы 3
- Сложнее — оставить как `regexp:` (фаза 3) с пометкой `lossy`
### 2c. UI: «Импорт Xray JSON»
В Routing tab кнопка → модалка с textarea (paste JSON):
- сначала dry_run, показывает preview:
```
Зона proxy: 1247 доменов, 2 CIDR (выйдет в туннель)
Зона direct: 156 доменов, 2 CIDR (мимо туннеля)
Пропущено: 3 правила (port-based, inboundTag)
```
- кнопка «Применить» → второй POST без dry_run, заменяет существующие зоны
| Файл | Что |
|---|---|
| `internal/server/server.go` | новый handler |
| `internal/services/awg/xray_import.go` (новый) | парсер + конвертер |
| `internal/services/awg/xray_regex.go` (новый) | regexp → wildcards |
| `frontend/src/...` | компонент XrayImportDialog |
## Phase 3 — Regexp / keyword runtime
`iptables --match-set` работает по IP, а не по домену. Domain-уровневый матч идёт
через `snisniff_linux.go` (читает SNI из TCP ClientHello).
### 3a. Хранение скомпилированных матчеров
| Файл | Что |
|---|---|
| `internal/services/awg/config.go` | в `Zone` новые поля: `Regexps []string`, `Keywords []string` |
| `internal/services/awgroute/sni.go` | при `Apply` компилировать regexp'ы один раз, держать кэш |
### 3b. Матчер в SNI sniffer
| Файл | Что |
|---|---|
| `internal/services/awgroute/snisniff_linux.go` | в hot-path callback'е (когда пришёл SNI): проверка по compiled regexp + substring keyword'ам. Матч → resolve(SNI host) → запихнуть IP в существующий ipset `awgSetSNI` |
Текущая инфраструктура (MARK по ipset, ip rule, ip route, NAT) переиспользуется
как есть.
### 3c. UI
Тот же textarea зоны автоматически принимает строки:
```
regexp:^(.*\.)?yandex\.(ru|com)$
keyword:porn
```
Парсер 1b и 2b их раскидывает по `Regexps`/`Keywords` (не путать с `Domains`).
## Сборка / деплой
```sh
cd /tmp/nfqws2-sel
npm --prefix frontend install
npm --prefix frontend run build # для фаз 1d, 2c
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \
-o /tmp/nfqws2-strategy ./cmd/nfqws2-strategy
scp /tmp/nfqws2-strategy root@192.168.31.1:/opt/usr/bin/nfqws2-strategy
ssh root@192.168.31.1 '
kill $(pidof nfqws2-strategy) 2>/dev/null
( /opt/usr/bin/nfqws2-strategy serve </dev/null >/opt/var/log/nfqws2-strategy/serve.log 2>&1 & )
'
```
## Smoke-тесты после деплоя
1. Фаза 0: зона `{source_ips:[".243"], domains:["*"]}`с .243 `ipinfo.io` показывает `185.209.21.51`, с другого устройства — реальный IP.
2. Фаза 1: импортить `geosite.dat`, зона с `geosite:cn` → доменов > 1000 в ответе `apply`.
3. Фаза 2: paste минимального xray JSON, dry_run, preview корректный.
4. Фаза 3: добавить `regexp:^(.*\.)?example\.com$`, открыть `https://sub.example.com` — IP попадает в ipset, трафик идёт в awg0.
## Что НЕ делаем (намеренно)
- `keyword:` substring через iptables `-m string` — пакеты могут быть фрагментированы, ложные срабатывания, не делаем
- `inboundTag` xray-стиля — у нас нет input-tag концепции, пропускаем с предупреждением
- `domainStrategy: IPIfNonMatch` и подобные — резолвим всё через выбранный `DomainSource`
- `port`/`protocol` правила — селектор работает на L3, не L4-routing, пропускаем

@ -0,0 +1,83 @@
# Server bench: 193.233.126.94 (Hysteria2 host)
**Дата**: 2026-07-09
**Хостер**: dhost.su (VM `1083448745-iwqfmwg.dhost.su`, 1 vCPU, 2 GB RAM, kernel 6.8)
**Роль**: VPN endpoint (Hysteria2 UDP :443)
**Тестовый сегмент**: WAN, роутер `88.201.133.115` (ISP SkyNet SPB) ↔ VPS
---
## 1. Reverse-path MTR: сервер → router WAN 88.201.133.115
```
HOST: 193.233.126.94 Loss% Snt Last Avg Best Wrst StDev
1. 94.103.1.1 0.0% 10 4.8 2.5 0.9 4.8 1.5 (AS209207 DHOST, RU)
2. 146.19.191.175 0.0% 10 0.9 4.4 0.8 18.3 6.7 (AS49581 Tube-Hosting, DE)
3. 185.0.44.28 0.0% 10 56.4 56.6 56.2 57.1 0.3 (transit)
4. 185.37.128.156 0.0% 10 55.3 55.0 54.7 55.6 0.4 (AS35807 SkyNet SPB)
5. 185.37.128.225 0.0% 10 54.6 54.6 54.5 55.2 0.2 (AS35807 SkyNet SPB)
6. 185.37.128.189 0.0% 10 57.3 56.9 56.5 57.4 0.4 (AS35807 SkyNet SPB)
7. 185.37.128.166 0.0% 10 57.5 70.2 57.4 115.1 19.0 (AS35807 SkyNet SPB)
8. ??? 100.0 10 (silent — обычно target-firewall)
```
**Вердикт**: путь **чистый**. 0% loss на всём отвечающем сегменте.
Тот же SkyNet AS35807 что в старом кейсе 31.76.103.176 (где `185.37.128.156` был StDev 1192ms, 30% loss) — сейчас работает как надо (Avg 55ms, StDev 0.2-0.4).
Лёгкий скачок на hop 7 (StDev 19ms) — терпимо.
---
## 2. Forward-path MTR: srv (192.168.31.243) → 193.233.126.94
```
HOST: len-srv Loss% Snt Last Avg Best Wrst StDev
1. 192.168.31.1 0.0% 10 1.2 1.5 1.1 2.5 0.5 (router)
2. 88.201.128.1 0.0% 10 2.0 1.8 1.7 2.0 0.1 (AS35807 SkyNet SPB — ISP)
3. 185.37.128.167 0.0% 10 1.9 2.0 1.8 2.3 0.1 (AS35807)
4. 185.37.128.188 0.0% 10 3.3 3.1 2.2 4.1 0.5 (AS35807)
5. ??? 100.0 10 (silent hop)
6. 185.37.128.157 0.0% 10 2.1 2.4 1.9 3.3 0.4 (AS35807)
7. 185.0.44.98 0.0% 10 54.3 56.1 53.1 75.5 6.8 (transit — RU→DE hop)
8. 146.19.191.174 0.0% 10 68.1 61.0 56.6 68.5 4.9 (AS49581 Tube-Hosting, DE)
9. 193.233.126.94 0.0% 10 57.0 56.9 55.1 57.9 0.7 (target)
```
**Вердикт**: чисто. 0% loss везде, target hop StDev 0.7ms.
---
## 3. iperf3 direct WAN (роутер → сервер, оба BBR)
| Test | Rate | Loss/Retr |
|---|---:|---|
| **UDP 300M UP** (router→server) | **298 Mbit/s** | 0/259004 packets (0%) |
| **UDP 300M DL** (server→router) | **299 Mbit/s** | 912/258982 packets (0.35%) |
| **TCP BBR UP** | **408 Mbit/s** | — |
| **TCP BBR DL** | **265 Mbit/s** | — |
**Вердикт**: физический path clean, TCP BBR даёт ~410/265 Mbit, UDP ~300M без потерь.
Download чуть проседает под нагрузкой (UDP 0.35% loss на 300M) — типичный ISP-shape в reverse-направлении.
---
## 4. Hysteria2 tunnel через этот сервер
| Test | Rate |
|---|---:|
| iperf3 UP через hys tunnel | 328 Mbit/s |
| iperf3 DL через hys tunnel | 250 Mbit/s |
| curl bench UP (cloudflare) через hys | 156 Mbit/s |
| curl bench DL (cachefly) через hys | 210 Mbit/s |
Overhead tunnel vs direct WAN: ~20% (QUIC + userspace TUN)
---
## Как читать (общие критерии для нового сервера)
- **Reverse-mtr — самое важное**. Server→router путь чаще всего убитый.
- Плохо: Avg > 100ms или StDev > 50ms или loss > 5% на любом хопе → single-TCP CUBIC загнётся в ~5 Mbit
- Хорошо: 0% loss везде, StDev < 10ms (как здесь)
- **Forward-mtr обычно чистый** — но всё равно надо иметь для сравнения
- **UDP iperf** = физический ceiling пути без TCP-CC шума
- **Delta UDP↔TCP** широкая (например UDP 400M / TCP 30M) = loss/jitter, CUBIC не тянет — нужен BBR либо Brutal

@ -0,0 +1,79 @@
# Server bench: 2.26.224.64
**Дата**: 2026-07-09
**Хостер**: tihost.com (`my-server-25.tihost.com`, 1 vCPU, 2 GB RAM, kernel 6.8)
**Тестовый сегмент**: WAN, роутер `88.201.133.115` (ISP SkyNet SPB) ↔ VPS
---
## 1. Reverse-path MTR: сервер → router WAN 88.201.133.115
```
HOST: 2.26.224.64 Loss% Snt Last Avg Best Wrst StDev
1. 10.0.0.1 0.0% 10 0.1 0.1 0.1 0.2 0.0 (private, VPS lan-gw)
2. 2.59.253.41 0.0% 10 0.2 0.2 0.2 0.3 0.0 (AS215730 H2NEXUS, GB)
3. 2.59.253.33 0.0% 10 0.2 0.2 0.2 0.3 0.0 (AS215730 H2NEXUS, GB)
4. 144.31.4.50 0.0% 10 2.0 2.6 1.8 6.6 1.6 (AS215730 H2NEXUS, GB)
5. 80.77.167.98 0.0% 10 2.5 3.6 1.8 11.1 3.1 (AS28917 Fiord Networks, LT)
6. 80.77.167.152 0.0% 10 29.6 23.1 20.3 32.0 4.1 (AS28917 Fiord, LT — jitter)
7. 62.140.245.32 0.0% 10 20.5 22.1 20.5 29.6 3.0 (AS51369 FIORD-RU, RU)
8. 62.140.245.203 0.0% 10 28.4 34.1 28.4 85.4 18.0 (AS51369 FIORD-RU — StDev 18!)
9. 93.191.9.59 0.0% 10 30.3 30.3 30.2 30.5 0.1 (AS51369 FIORD-RU)
10. ??? 100.0 10 (silent)
11. 185.37.128.225 0.0% 10 28.6 28.7 28.6 28.9 0.1 (AS35807 SkyNet SPB)
12. 185.37.128.189 0.0% 10 28.7 28.7 28.6 28.8 0.1 (AS35807 SkyNet SPB)
13. 185.37.128.166 0.0% 10 30.6 30.7 30.6 30.8 0.1 (AS35807 SkyNet SPB)
14. ??? 100.0 10 (silent — target FW)
```
**Вердикт**: путь **чистый** (0% loss везде).
- Hop 8 (62.140.245.203) StDev **18ms** — единственная точка с джиттером, но loss 0%.
- RTT ~30ms до target.
- Транзит: H2NEXUS (GB) → Fiord (LT) → FIORD-RU → SkyNet SPB. Международка, но без потерь.
- **Лучше чем 31.76 и 2.26.252.43** — 0% loss везде vs 10%.
---
## 2. Forward-path MTR: srv → 2.26.224.64
```
HOST: len-srv Loss% Snt Last Avg Best Wrst StDev
1. 192.168.31.1 0.0% 10 1.2 1.4 1.1 1.8 0.2 (router)
2. ??? 100.0 10 (silent)
3. 2.26.224.64 0.0% 10 2.4 2.5 1.7 3.2 0.4 (target)
```
**Вердикт**: цель в 3 хопах, RTT 2.5ms — идёт через tunnel.
---
## 3. iperf3 direct WAN (роутер → сервер, оба BBR)
| Test | Rate | Loss/Retr |
|---|---:|---|
| **UDP 300M UP** (router→server) | **184 Mbit/s** | 102348/274044 packets (**37%**) |
| **UDP 300M DL** (server→router) | **178 Mbit/s** | 101325/263836 packets (**38%**) |
| **TCP BBR UP** | **341 Mbit/s** | — |
| **TCP BBR DL** | **179 Mbit/s** | — |
**Вердикт**: TCP UP 341M ~ такой же ceiling как у SRV1 (тот же ISP-shape ~180M UDP).
TCP DL 179 Mbit — **в 1.6× выше чем у 2.26.252.43** (там 111M) — потому что reverse-mtr тут чистый vs 10% loss на 252.43.
---
## Сравнение всех замеренных серверов
| Сервер | RTT (rev) | Rev-mtr worst | TCP UP | TCP DL | UDP 300M loss |
|---|---:|---|---:|---:|---:|
| **193.233.126.94** (dhost.su) | 57 ms | 0% | **408** | 265 | 0% / 0.35% |
| **2.26.224.64** (tihost.com) | 30 ms | 0% (StDev 18ms) | 341 | 179 | 37% / 38% |
| **2.26.252.43** (tihost.com) | 41 ms | 10% loss | 332 | 111 | 37% / 39% |
| **31.76.103.176** (tihost.com) | 27 ms | 10% loss (Telia) | 89 ⚠ | 239 | 44% / 38% |
**Итоги**:
- **193.233.126.94 — рекордсмен** (408 Mbit UP, 0% UDP loss, чистый reverse-path)
- **2.26.224.64 — второй** (341/179, 0% loss)
- **2.26.252.43 — DL слабый** (111 Mbit) из-за reverse-loss
- **31.76.103.176 — только для build** (89 UP)
Все tihost.com имеют один ISP-cap ~180 Mbit UDP UP. dhost.su (193.233) выделяется полосой ×2.

@ -0,0 +1,66 @@
# Server bench: 2.26.252.43
**Дата**: 2026-07-09
**Хостер**: tihost.com (`my-server-24.tihost.com`, 1 vCPU, 2 GB RAM, kernel 6.8)
**Тестовый сегмент**: WAN, роутер `88.201.133.115` (ISP SkyNet SPB) ↔ VPS
---
## 1. Reverse-path MTR: сервер → router WAN 88.201.133.115
```
HOST: 2.26.252.43 Loss% Snt Last Avg Best Wrst StDev
1. 10.0.0.1 0.0% 10 0.2 0.2 0.1 0.2 0.0 (private, VPS lan-gw)
2. ??? 100.0 10 (silent)
3. ??? 100.0 10 (silent)
4. ??? 100.0 10 (silent)
5. ??? 100.0 10 (silent)
6. ??? 100.0 10 (silent)
7. ??? 100.0 10 (silent)
8. 80.64.103.55 0.0% 10 41.1 41.0 40.7 41.4 0.2 (AS20764 RASCOM, RU)
9. ??? 100.0 10 (silent)
10. 185.37.128.225 0.0% 10 41.1 41.0 40.8 41.4 0.2 (AS35807 SkyNet SPB)
11. ??? 100.0 10 (silent)
12. 185.37.128.166 10.0% 10 41.2 41.3 41.0 41.8 0.3 (AS35807 SkyNet SPB — 10% loss)
13. ??? 100.0 10 (silent — target FW)
```
**Вердикт**: путь **терпимый, но не блестящий**.
- Много silent-хопов (7 подряд) — операторы фильтруют ICMP-TTL — не проблема сама по себе.
- **hop 12 (SkyNet SPB) держит 10% loss** — та же точка что мучала 31.76 сервер (было там же).
- RTT ~41ms стабильный, StDev < 0.5ms.
- Транзит через RASCOM (RU AS20764) → сразу к SkyNet SPB, без международки. Короткий но с шумом.
---
## 2. Forward-path MTR: srv → 2.26.252.43
```
HOST: len-srv Loss% Snt Last Avg Best Wrst StDev
1. 192.168.31.1 0.0% 10 2.8 2.3 1.4 2.8 0.4 (router)
2. ??? 100.0 10 (silent)
3. 2.26.252.43 0.0% 10 1.3 1.4 1.2 1.6 0.1 (target)
```
**Вердикт**: цель в 3 хопах, RTT 1.4ms — идёт через tunnel (не WAN).
---
## 3. iperf3 direct WAN (роутер → сервер, оба BBR)
| Test | Rate | Loss/Retr |
|---|---:|---|
| **UDP 300M UP** (router→server) | **183 Mbit/s** | 101710/274135 packets (**37%**) |
| **UDP 300M DL** (server→router) | **177 Mbit/s** | 103307/264662 packets (**39%**) |
| **TCP BBR UP** | **332 Mbit/s** | — |
| **TCP BBR DL** | **111 Mbit/s** | ⚠ низкий |
**Вердикт**: TCP UP норм 332M, но **TCP DL всего 111 Mbit** — коррелирует с 10% loss на reverse-mtr hop 12.
UDP 300M target тонет на ~180 Mbit — потолок ISP.
---
## Как читать
- **UDP 300M ceiling ~180 Mbit** = тот же ISP-shape что у 31.76.
- **TCP DL 111 Mbit** = 10% loss + BBR не спасает.

@ -0,0 +1,91 @@
# Server bench: 31.76.103.176 (build-server, AWG endpoint `router-correct`)
**Дата**: 2026-07-09
**Хостер**: tihost.com (`my-server-1.tihost.com`, 1 vCPU, 2 GB RAM, kernel 6.8)
**Роль**: билд-сервер для amneziawg.ko / tcp_bbr.ko, также AWG endpoint (тоннель `awg-f1faca2af5a6`, DISABLED)
**Тестовый сегмент**: WAN, роутер `88.201.133.115` (ISP SkyNet SPB) ↔ VPS
---
## 1. Reverse-path MTR: сервер → router WAN 88.201.133.115
```
HOST: 31.76.103.176 Loss% Snt Last Avg Best Wrst StDev
1. 10.0.0.1 0.0% 10 0.2 0.1 0.1 0.2 0.0 (private, VPS lan-gw)
2. 10.0.102.254 0.0% 10 0.6 0.7 0.5 0.9 0.1 (private)
3. ??? 100.0 10 (silent hop)
4. 100.64.120.0 0.0% 10 0.9 0.9 0.8 1.3 0.2 (CGNAT)
5. 62.115.199.250 0.0% 10 0.8 0.9 0.8 1.1 0.1 (AS1299 Arelion, SE)
6. 62.115.137.119 0.0% 10 6.3 8.2 1.5 26.1 9.6 (AS1299 Arelion — jitter!)
7. 62.115.122.34 0.0% 10 24.6 24.6 24.5 24.8 0.1 (AS1299 Arelion)
8. 62.115.141.23 0.0% 10 23.3 23.7 23.2 25.0 0.5 (AS1299 Arelion)
9. 62.115.135.109 10.0% 10 35.3 28.9 27.8 35.3 2.4 (AS1299 Arelion — Telia Moscow)
10. 87.245.255.42 0.0% 10 26.8 26.6 26.3 26.8 0.1 (AS9002 RETN, GB)
11. 139.45.243.65 0.0% 10 27.1 27.8 26.6 31.2 1.5 (AS57304 RETN, RU)
12. 139.45.238.85 0.0% 10 26.9 26.8 26.5 27.0 0.2 (AS57304 RETN, RU)
13. 185.37.128.156 0.0% 10 27.4 28.4 26.9 39.3 3.8 (AS35807 SkyNet SPB)
14. 185.37.128.225 0.0% 10 27.0 26.9 26.6 27.1 0.1 (AS35807 SkyNet SPB)
15. 185.37.128.189 0.0% 10 27.3 27.3 27.0 27.7 0.2 (AS35807 SkyNet SPB)
16. 185.37.128.166 0.0% 10 27.3 27.1 26.9 27.3 0.1 (AS35807 SkyNet SPB)
17. ??? 100.0 10 (silent — target firewall)
```
**Вердикт**: путь **очень хороший** сейчас. В прошлом здесь ловился Arelion→SkyNet-транзит с Avg 1002ms / StDev 1192ms / 30% loss (см. [[project-multitunnel-status]] и переписку). Сейчас:
- 62.115.135.109 (Telia Moscow) держит 10% loss — единственная точка, где что-то теряется. Было 30%, сейчас лучше.
- 185.37.128.156 (тот кейс с 1002ms) теперь Avg 28ms, StDev 3.8ms — грязный транзит прошёл.
**Note**: транзит через Arelion (шведский AS1299), потом RETN (GB→RU), потом SkyNet (RU). 4-хопная международка. Уязвим к пиковым нагрузкам.
---
## 2. Forward-path MTR: srv (192.168.31.243) → 31.76.103.176
```
HOST: len-srv Loss% Snt Last Avg Best Wrst StDev
1. 192.168.31.1 0.0% 10 1.4 1.4 1.2 1.6 0.1 (router)
2. ??? 100.0 10 (silent)
3. 31.76.103.176 0.0% 10 2.6 2.0 1.3 3.0 0.7 (target)
```
**Вердикт**: **3 хопа и Avg 2ms — идёт через tunnel** (не через WAN). Активный AWG main-tunnel сейчас = `amnezia_for_awg` → 185.209.21.51, но 31.76.103.176 сидит в том же DC/AS peering, поэтому tunnel-эндпоинт→31.76 = один hop.
---
## 3. iperf3 direct WAN (роутер → 31.76.103.176, оба BBR)
| Test | Rate | Loss/Retr |
|---|---:|---|
| **UDP 300M UP** (router→server) | **165 Mbit/s** | 123534/278129 packets (**44%**) |
| **UDP 300M DL** (server→router) | **184 Mbit/s** | 106127/277071 packets (**38%**) |
| **TCP BBR UP** | **89 Mbit/s** | — |
| **TCP BBR DL** | **239 Mbit/s** | — |
**Вердикт**: **аплоад к этому серверу зашивается на ~180 Mbit**. При попытке 300M UDP 44% пакетов теряется — где-то на пути (скорее всего hop 9 Telia Moscow или дальше на пути Arelion→RETN→SkyNet) стоит рейт-лимит ~180 Mbit.
TCP-UP всего 89 Mbit — при таких потерях даже BBR не спасает.
---
## 4. Сравнение с 193.233.126.94
| Метрика | 31.76.103.176 | 193.233.126.94 |
|---|---:|---:|
| RTT (reverse-mtr avg) | ~27 ms | ~57 ms |
| Reverse-mtr loss worst hop | 10% (Telia) | 0% |
| UDP UP 300M actual | 165 Mbit (44% loss) | **298 Mbit (0% loss)** |
| UDP DL 300M actual | 184 Mbit (38% loss) | **299 Mbit (0.35% loss)** |
| TCP BBR UP | 89 Mbit | **408 Mbit** |
| TCP BBR DL | 239 Mbit | 265 Mbit |
**193.233.126.94 в 4.5× быстрее по TCP UP** и без потерь в UDP. 31.76 годится только для build (компиляция ядра) — не для тоннеля.
---
## Как читать (общие критерии)
- **Reverse-mtr — самое важное**. Server→router путь чаще всего убитый.
- Плохо: Avg > 100ms или StDev > 50ms или loss > 5% на любом хопе → single-TCP CUBIC загнётся в ~5 Mbit
- Хорошо: 0% loss везде, StDev < 10ms
- **UDP iperf с высоким target = тест реальной физики**. Loss > 5% на 300M target = ISP или транзит режет.
- **Delta UDP↔TCP** широкая = потери / jitter → нужен BBR либо Brutal.
- **Международный транзит через много AS** (как тут Arelion→RETN→SkyNet) уязвим — может внезапно проседать.

@ -0,0 +1,79 @@
# Сводная таблица VPS-серверов (по состоянию 2026-07-09)
Тестовый сегмент: роутер `88.201.133.115` (Санкт-Петербург, SkyNet ISP) → VPS
---
## RTT / пинги
| Сервер | Хостер | Reverse-mtr Avg | Reverse-mtr StDev (worst hop) | Forward-mtr Avg (srv→target) |
|---|---|---:|---:|---:|
| **193.233.126.94** | dhost.su | ~57 ms | 19.0 ms (hop 7) | 57 ms |
| **2.26.224.64** | tihost.com | ~30 ms | **18.0 ms** (hop 8, FIORD-RU) | 2.5 ms |
| **2.26.252.43** | tihost.com | ~41 ms | 0.5 ms | 1.4 ms |
| **31.76.103.176** | tihost.com | ~27 ms | 9.6 ms (hop 6, Arelion) | 2.0 ms |
*Reverse-mtr = server→router, самое важное для CUBIC single-flow*
*Forward-mtr короткий (2-3 хопа) значит цель достижима через активный тоннель, а не WAN*
---
## Потери / джиттер (по iperf3 UDP 300 Mbit target)
### UP (router → server)
| Сервер | Rate | Джиттер | Потери |
|---|---:|---:|---:|
| **193.233.126.94** | **298 Mbit** | **0.038 ms** | **0 / 259004 (0%)** |
| 2.26.224.64 | 184 Mbit | 0.085 ms | 102348 / 274044 (37%) |
| 2.26.252.43 | 183 Mbit | 0.079 ms | 101710 / 274135 (37%) |
| 31.76.103.176 | 165 Mbit | 0.108 ms | 123534 / 278129 (**44%**) |
### DL (server → router)
| Сервер | Rate | Джиттер | Потери |
|---|---:|---:|---:|
| **193.233.126.94** | **299 Mbit** | 0.062 ms | 912 / 258982 (0.35%) |
| 2.26.224.64 | 178 Mbit | 0.100 ms | 101325 / 263836 (38%) |
| 2.26.252.43 | 177 Mbit | **8.718 ms** ⚠ | 103307 / 264662 (39%) |
| 31.76.103.176 | 184 Mbit | 0.090 ms | 106127 / 277071 (38%) |
**Замечания**:
- 193.233 — единственный сервер, где 300 Mbit UDP реально держится (0% loss). У tihost'ов физический cap ~180 Mbit — при попытке 300M половина пакетов теряется.
- 2.26.252.43 DL джиттер **8.7 ms** (у всех остальных <0.1) точечная проблема пути или очередь на промежуточном хопе.
---
## TCP throughput (BBR обеих сторон)
| Сервер | TCP UP | TCP DL | Ретрансмиты UP | Ретрансмиты DL |
|---|---:|---:|---:|---:|
| **193.233.126.94** | **408 Mbit** | 265 Mbit | низкие | 117 |
| 2.26.224.64 | 341 Mbit | 179 Mbit | — | — |
| 2.26.252.43 | 332 Mbit | **111 Mbit** ⚠ | — | — |
| 31.76.103.176 | **89 Mbit** ⚠ | 239 Mbit | — | — |
**Замечания**:
- **193.233 в 1.2× быстрее** второго (2.26.224.64) по TCP UP, и в **4.5× быстрее** 31.76.
- 31.76 TCP UP всего 89 Mbit — там аплоад-cap где-то на пути (Arelion→Telia Moscow→RETN).
- 2.26.252.43 DL 111 Mbit — коррелирует с 10% loss на reverse-mtr hop 12.
---
## Reverse-path критические хопы (что убивает CUBIC)
| Сервер | Хоп-провал | Loss | StDev | AS |
|---|---|---:|---:|---|
| **193.233.126.94** | нет | 0% | 0.4 (hop 6) | — |
| 2.26.224.64 | 62.140.245.203 | 0% | **18 ms** | AS51369 FIORD-RU |
| 2.26.252.43 | 185.37.128.166 | **10%** | 0.3 | AS35807 SkyNet |
| 31.76.103.176 | 62.115.135.109 | **10%** | 2.4 | AS1299 Arelion (Telia Moscow) |
---
## Итоговый рейтинг
1. **193.233.126.94** — эталон. 0% loss, 0.04 ms джиттер, TCP UP 408 M. **Основной тоннель.**
2. **2.26.224.64** — резерв. Rev-mtr чистый по loss, но hop 8 StDev 18 ms → пиковые задержки. TCP UP 341M годится.
3. **2.26.252.43** — TCP DL всего 111 Mbit из-за 10% reverse-loss. Годится **только для UP**.
4. **31.76.103.176** — только для build (ядро/модули). Как VPN — не годится.
**Общий паттерн**: у всех трёх tihost.com одинаковый ISP-cap ~180 Mbit UDP UP к моему WAN (SkyNet SPB). Отличие только в качестве reverse-path (loss/jitter). dhost.su на 193.233 сидит в другой сетке где этот cap не действует.

@ -0,0 +1,107 @@
# Отчёт по производительности VPS
**Дата тестирования:** 2026-07-09
**Тестовый сегмент:** VPS ↔ клиентский роутер `88.201.133.115` (SPB, AS35807 SkyNet)
**Инструменты:** `mtr` (10 циклов), `iperf3` (10 сек, BBR обеих сторон)
**Сравнение:** 4 сервера, три из них у tihost.com (одинаковый ISP-пафс), один у dhost.su
---
## Сводная таблица
| Сервер | Хостер | RTT<br/>(мс) | Reverse-path<br/>MTR loss | Reverse-path<br/>MTR max StDev | TCP UP<br/>(Mbit/s) | TCP DL<br/>(Mbit/s) | UDP 300 Mbit<br/>UP loss | UDP 300 Mbit<br/>DL loss | UDP<br/>UP jitter (мс) | UDP<br/>DL jitter (мс) |
|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 193.233.126.94 | dhost.su | 57 | 0% | 19.0 | **408** | 265 | 0% | 0.35% | 0.038 | 0.062 |
| 2.26.224.64 | tihost.com | 30 | 0% | 18.0 | 341 | 179 | 37% | 38% | 0.085 | 0.100 |
| 2.26.252.43 | tihost.com | 41 | 10% | 0.3 | 332 | **111** | 37% | 39% | 0.079 | **8.718** |
| 31.76.103.176 | tihost.com | 27 | 10% | 2.4 | **89** | 239 | 44% | 38% | 0.108 | 0.090 |
Жирным выделены проблемные значения.
---
## Наблюдение №1: у трёх серверов tihost.com одинаковый cap ~180 Mbit UDP
При попытке залить 300 Mbit UDP в обе стороны все три tihost-сервера теряют 37-44% пакетов и держат стабильные 165-184 Mbit. У dhost-сервера (193.233.126.94) те же 300 Mbit проходят с 0-0.35% потерь на 298-299 Mbit.
Это указывает на **shape/rate-limit ~180 Mbit** где-то на пути между tihost и клиентским ISP (AS35807 SkyNet SPB). При этом physical-link tihost'а явно шире — TCP BBR всё ещё вытягивает 341/332 Mbit там где UDP теряется.
## Наблюдение №2: две точки с 10% packet loss в reverse-path
MTR `mtr -c 10 -rn 88.201.133.115` с серверов:
**2.26.252.43 → hop 12 (185.37.128.166 SkyNet SPB):**
```
10. 185.37.128.225 0.0% loss Avg 41.0 ms StDev 0.2
11. ??? (silent)
12. 185.37.128.166 10.0% loss Avg 41.3 ms StDev 0.3
```
**31.76.103.176 → hop 9 (62.115.135.109 Arelion Telia Moscow):**
```
8. 62.115.141.23 0.0% loss Avg 23.7 ms StDev 0.5
9. 62.115.135.109 10.0% loss Avg 28.9 ms StDev 2.4
10. 87.245.255.42 0.0% loss Avg 26.6 ms
```
Эти потери коррелируют с деградацией TCP-throughput:
- 2.26.252.43 TCP DL = 111 Mbit (у соседа 2.26.224.64 без loss = 179 Mbit)
- 31.76.103.176 TCP UP = 89 Mbit (у соседа 2.26.224.64 без loss = 341 Mbit)
## Наблюдение №3: аномальный DL-джиттер на 2.26.252.43
UDP-джиттер измеренный iperf3:
```
2.26.252.43 DL jitter: 8.718 ms
2.26.224.64 DL jitter: 0.100 ms
193.233.126.94 DL jitter: 0.062 ms
31.76.103.176 DL jitter: 0.090 ms
```
8.7 мс vs 0.1 мс у других — межпакетная дрожь в 87× выше нормы для конкретно этого сервера в DL-направлении. При тех же ~40 мс RTT.
## Наблюдение №4: peak-джиттер на hop 8 (2.26.224.64)
Reverse-mtr с 2.26.224.64:
```
8. 62.140.245.203 0.0% loss Avg 34.1 ms StDev 18.0 Wrst 85.4 ms
```
Потерь нет, но конкретный пакет может опоздать на 85 мс при среднем 34 мс. Это AS51369 FIORD-RU. Не критично для usable throughput, но для low-latency (VoIP/games) неприятно.
---
## Что просим у саппорта
1. **Проверить рейт-лимит ~180 Mbit к AS35807 SkyNet SPB (88.201.133.115).** У трёх ваших серверов одинаковый potolок при том что физика гораздо шире (TCP пробивает выше). Похоже на настройку shape на исходящем пиринге.
2. **10% loss на конкретных хопах:**
- `185.37.128.166` в pop-е SkyNet SPB (виден с 2.26.252.43)
- `62.115.135.109` (Telia Moscow, AS1299 Arelion) — виден с 31.76.103.176
Оба стабильно теряют 1 из 10 пакетов при разных RTT/StDev — это не burst-loss.
3. **Аномальный DL-джиттер 8.7 мс на 2.26.252.43** — не воспроизводится у соседа в том же ДЦ (`2.26.224.64` = 0.1 мс). Проверить очереди на исходящем интерфейсе именно этого узла.
Готов предоставить сырые `mtr`/`iperf3` логи по запросу.
---
## Методология (для воспроизведения)
```bash
# reverse-path
ssh root@<vps> 'mtr -c 10 -rn 88.201.133.115'
# forward-path
ssh root@<router> 'mtr -c 10 -rn <vps>'
# iperf3 direct WAN (оба BBR)
ssh root@<vps> 'iperf3 -s -D -p 5201'
ssh root@<router> 'iperf3 -c <vps> -p 5201 -t 10 -f m' # TCP UP
ssh root@<router> 'iperf3 -c <vps> -p 5201 -R -t 10 -f m' # TCP DL
ssh root@<router> 'iperf3 -c <vps> -p 5201 -u -b 300M -t 10' # UDP UP 300M
ssh root@<router> 'iperf3 -c <vps> -p 5201 -u -b 300M -R -t 10' # UDP DL 300M
```
Все VPS — Ubuntu 24.04, kernel 6.8, `sysctl net.ipv4.tcp_congestion_control=bbr`, `net.core.default_qdisc=fq`.

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Прогон нового server-кандидата (VPN endpoint).
# Usage: ./bench-server.sh <server_ip> [ssh-user] [ssh-port]
#
# Даёт три вещи в связке:
# 1. Reverse-path MTR с сервера на роутер — ловит asymmetric-routing грязный transit
# (это уже раз убивало single-TCP до 5 Mbit — см. AS35807 SkyNet SPB кейс).
# 2. Forward-path MTR с srv/роутера — обычно чистый, но всё равно надо иметь.
# 3. iperf3 UDP направленный тест — показывает реальный ceiling пути без TCP CC-шума.
set -e
SRV="${1:?server_ip required}"
SUSER="${2:-root}"
SPORT="${3:-22}"
# фиксированные для этой инфры
ROUTER_WAN="88.201.133.115" # твой публичный WAN, для reverse-mtr с сервера
ROUTER_LAN="192.168.31.1" # sshpass root:root
LAN_CLIENT="srv" # ssh alias — WSL Ubuntu за роутером
echo "═══ Инспекция сервера $SRV ═══"
ssh -p "$SPORT" "$SUSER@$SRV" 'hostname; uname -r; nproc; free -m | head -2; ip -4 addr | grep inet' || {
echo "! SSH не пускает — нужен пароль. Задай sshpass -p '...' вручную."
exit 1
}
echo ""
echo "═══ 1. Reverse-path MTR: server → router WAN ($ROUTER_WAN) ═══"
echo " (высокий StDev/loss на конкретном hop = грязный transit — плохо для CUBIC single-flow)"
ssh -p "$SPORT" "$SUSER@$SRV" "which mtr || (apt-get install -qq -y mtr-tiny 2>/dev/null); mtr -c 10 -rn $ROUTER_WAN"
echo ""
echo "═══ 2. Forward-path MTR: srv → server ═══"
ssh srv "which mtr || sudo apt-get install -qq -y mtr-tiny; mtr -c 10 -rn $SRV"
echo ""
echo "═══ 3. Forward MTR: srv → 8.8.8.8 (baseline через текущий main tunnel) ═══"
ssh srv "mtr -c 10 -rn 8.8.8.8"
echo ""
echo "═══ 4. iperf3 UDP 300 Mbit с роутера → сервер (сырой path без TCP CC) ═══"
echo " Устанавливаю iperf3 на сервере и запускаю в фоне..."
ssh -p "$SPORT" "$SUSER@$SRV" 'which iperf3 || apt-get install -qq -y iperf3 2>/dev/null; pkill -f "iperf3 -s" 2>/dev/null; iperf3 -s -D -p 5201'
sleep 2
echo " UDP 300M UP (router → server):"
sshpass -p 'root' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@$ROUTER_LAN \
"/opt/bin/iperf3 -c $SRV -p 5201 -u -b 300M -t 10 -f m 2>&1 | grep receiver | tail -1"
echo " UDP 300M DL (server → router):"
sshpass -p 'root' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@$ROUTER_LAN \
"/opt/bin/iperf3 -c $SRV -p 5201 -u -b 300M -R -t 10 -f m 2>&1 | grep receiver | tail -1"
echo ""
echo "═══ 5. iperf3 TCP (BBR both sides) ═══"
sshpass -p 'root' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@$ROUTER_LAN \
"echo ' TCP UP: ' && /opt/bin/iperf3 -c $SRV -p 5201 -t 10 -f m 2>&1 | grep receiver | tail -1
echo ' TCP DL: ' && /opt/bin/iperf3 -c $SRV -p 5201 -R -t 10 -f m 2>&1 | grep receiver | tail -1"
echo ""
echo "═══ Как читать ═══"
echo " • MTR reverse: любой hop с Avg > 100ms или StDev > 50ms или loss > 5% → путь с сервера уебан."
echo " Такой сервер уебёт single-TCP CUBIC до ~5 Mbit несмотря на любой раmax UDP."
echo " • UDP iperf: показывает physical-cap без TCP-CC-шума."
echo " • Delta UDP↔TCP: если UDP 400M а TCP 30M — потери / jitter (CUBIC не тянет), нужен BBR/Brutal."
echo " • Если reverse-mtr чистый (Avg <60ms, StDev<10ms, 0%loss) — сервер годится."

@ -0,0 +1,222 @@
_MTD_partitions_:
0 > addr: 00000000 size: 00100000 ro: 0 name: "0:SBL1"
1 > addr: 00100000 size: 00100000 ro: 0 name: "0:SBL1_1"
2 > addr: 00200000 size: 00100000 ro: 0 name: "0:MIBIB"
3 > addr: 00300000 size: 00080000 ro: 0 name: "0:BOOTCONFIG"
4 > addr: 00380000 size: 00080000 ro: 0 name: "0:BOOTCONFIG1"
5 > addr: 00400000 size: 00200000 ro: 0 name: "0:QSEE"
6 > addr: 00600000 size: 00200000 ro: 0 name: "0:QSEE_1"
7 > addr: 00800000 size: 00080000 ro: 0 name: "0:DEVCFG"
8 > addr: 00880000 size: 00080000 ro: 0 name: "0:DEVCFG_1"
9 > addr: 00900000 size: 00080000 ro: 0 name: "0:APDP"
10 > addr: 00980000 size: 00080000 ro: 0 name: "0:APDP_1"
11 > addr: 00A00000 size: 00080000 ro: 0 name: "0:TME"
12 > addr: 00A80000 size: 00080000 ro: 0 name: "0:TME_1"
13 > addr: 00B00000 size: 00080000 ro: 0 name: "0:RPM"
14 > addr: 00B80000 size: 00080000 ro: 0 name: "0:RPM_1"
15 > addr: 00C00000 size: 00080000 ro: 0 name: "0:CDT"
16 > addr: 00C80000 size: 00080000 ro: 0 name: "0:CDT_1"
17 > addr: 00D00000 size: 00080000 ro: 0 name: "0:APPSBLENV"
18 > addr: 00D80000 size: 00100000 ro: 0 name: "0:APPSBL"
19 > addr: 00E80000 size: 00100000 ro: 0 name: "0:APPSBL_1"
20 > addr: 00F80000 size: 00100000 ro: 1 name: "0:ART"
21 > addr: 01080000 size: 00080000 ro: 0 name: "0:TRAINING"
22 > addr: 01100000 size: 00040000 ro: 0 name: "0:LICENSE"
23 > addr: 01140000 size: 02800000 ro: 0 name: "rootfs"
24 > addr: 03940000 size: 02800000 ro: 0 name: "rootfs_1"
25 > addr: 06140000 size: 00080000 ro: 1 name: "bdata"
26 > addr: 061C0000 size: 00080000 ro: 0 name: "crash"
27 > addr: 06240000 size: 00080000 ro: 0 name: "crash_syslog"
28 > addr: 062C0000 size: 01C00000 ro: 0 name: "overlay"
29 > addr: 07EC0000 size: 00040000 ro: 0 name: "KF"
30 > addr: FFFFFFFF size: 003AB914 ro: 0 name: "kernel"
31 > addr: FFFFFFFF size: 02017000 ro: 0 name: "ubi_rootfs"
32 > addr: FFFFFFFF size: 01838000 ro: 1 name: "cfg"
_Base_info_:
Linux stamp: Linux XiaoQiang 5.4.164 #0 SMP PREEMPT Tue Jan 27 03:33:27 2026 aarch64 GNU/Linux
Linux version: 5.4.164
CPU arch: arm64
CPU name: ipq95xx
SPI rom: False
_Kernel_command_line_:
ubi.mtd=rootfs
root=mtd:ubi_rootfs
rootfstype=squashfs
cnss2.bdf_pci2=0x02
cnss2.enable_mlo_support=0
rootwait
uart_en=1
_NVRam_params_:
CountryCode=CN
Router_unconfigured=0
SN=44594/14YE00117
boot_wait=on
bootargs=ubi.mtd=rootfs root=mtd:ubi_rootfs rootfstype=squashfs cnss2.bdf_pci2=0x02 cnss2.enable_mlo_support=0 rootwait
bootcmd=bootmiwifi
bootdelay=3
bootmenu_delay=5
color=100
crash_write_count=2
eth1addr=58:ea:1f:78:e1:ac
eth2addr=58:ea:1f:78:e1:ad
eth3addr=ee:31:75:9a:f3:b2
eth4addr=ee:31:75:9a:f3:b2
eth5addr=ee:31:75:9a:f3:b2
ethaddr=58:ea:1f:4a:2b:bc
ethprime=eth1
fdt_high=0x48500000
fdtcontroladdr=4a4e4004
flag_boot_rootfs=0
flag_boot_success=1
flag_boot_type=2
flag_flash_permission=1
flag_last_success=0
flag_ota_reboot=0
flag_show_upgrade_info=1
flag_try_sys1_failed=0
flag_try_sys2_failed=0
flash_type=11
fsbootargs=ubi.mtd=rootfs root=mtd:ubi_rootfs rootfstype=squashfs cnss2.bdf_pci2=0x02 cnss2.enable_mlo_support=0
ipaddr=192.168.31.1
machid=8050501
mode=Router
model=RC06
mtdids=nand0=nand0
netmask=255.255.255.0
no_wifi_dev_times=0
rand_key=1v83pS+jQk44UMaY3U8YWrSapXcvNpzz3a2ualPGkDJ1I27tx4Sq7w==
rand_nonce=webhbGM9UQcONmqKea3lK7u5gz1B9T6RZ8NlZDTOF5Tv+7+KXOFXYltnvH+H/3aaRNJZoXWhfvg4vmfukKEun1Ehou+nPbpG4scJiiouXd98TiuMXLrkGc8pQLVh4nQK1DqvDnuUx50S4mwQPYwUnd/eEbbMOrcdKP35mniDD9lgUC8ZTT7v5RSfrM2EkQ6E5GUGCLbR+WaKk4JxO+Cc2SWJLbIQGRZfmxPhajukXvldooly3M2Z1wvp7SyPXd4zl82shzDzHZ1zwO9NPGq7RGihrYt029OGfk35TyyV1JlT03e7UN8P8q4vMcJWFFCTQcm7GHxy85TW7JBsMuW3USZ26v7pCiVgI0u3Z1qVUxEVeGuax0sv2QUeHopiFQCFF7DjlzpwvAjmYXM+MXTBLmOdKG0bj0DARrI7vgDPsP8FHZDgxDZX4nimdWw1SkqLFsJ+jE5+r82/QkRXBkBv33epFSPycY0QVAyb8tcqF7/lhrJEeXWqSvp6u2vZqmaRD6YJMJ+r33Zc8Waj4955ATPCtTBTsa6Cz6IQ7EhOm0d77DuBrKmsvv07rsSL47h/dYRXr/phEsAWk7de4QMkVOGG/vL/NdS+7cLHE7udcjqKlujG6K1JmbWdcLG+5xmf0Qe47Ok6Utvpf5xG1ct41J88mvLR7OS5lxi92yMwJ3W/AAO+Qep6NMZjKJOzZTScT4eyqZiQYORv/36gP1L7hoUonULjMg5tFyndy5OTlwmXRWu7Kiln+kGGvm7CFZfhQkNUNoAH9sctGNug43l/APh+xbgCeGRCLFUerRjrSMbuiOJ8A0SOrLm9HVbfozY2X/rj6g/dShtaKx6ihP9TPO0alGfRTktpSZALOgvpNQst/8Wk95eOdRch4PChDxJQpLW4Jjl0X4QpV6y+pRTqQOT8O1t/PjQIbOiFZjjB6WWq6TSxndxsmi+XuAh4pP7xmCYqAu601o03mddyjZ09ONhwNuB4ksfKRjCykm7LwUDctNYnRtm8eM3HG+qfqzjZFLvr7ZF3/XFNRHKAegk2tRuL13val1AbRFzj0EOQL5HgY6UPAPyCqk6uwfWKQtXV01ODxthcRdghtDkIDWdpl5zke/uO3FIEYqJZI3em/Q9GcXILwqIMvCnthW9VqcxJypK7kmnvNziJG0Z2CvKrn5y4Cl9J3npTo6kjPjlQUx5D1bm6/UipamPRCklWsBDXgFpVkPZ8kiPD3uuEiG8E4TqPeL4ZTPkuGXGENn+vUatQ52Dog5iTfIG02vUh7bxDGHtFdCzxwp9oOvs9kodqW5dSLbQOvGsEjibm9iEYYTMW56CfnyNTehR4SzS3tZuOyn2wErBTiAGgpa16GUh8eQ==
restore_defaults=0
serverip=192.168.31.100
soc_version_major=1
soc_version_minor=1
ssh_en=1
stderr=serial@78B1000
stdin=serial@78B1000
stdout=serial@78B1000
telnet_en=0
uart_en=1
wl0_radio=1
wl0_ssid=Xiaomi_5G
wl1_radio=1
wl1_ssid=Xiaomi
wl2_radio=1
wl2_ssid=Xiaomi_5G-2
_RootFS_current_:
num = 0
mtd_num = None
mtd_dev = "None"
partition = "rootfs"
_Version_info_:
UBoot: None
OpenWrt: unknown
Firmware: 1.1.38
Channel: release
BuildTime: Tue, 27 Jan 2026 04:23:05 +0000
Hardware: RC06
UBoot(2): 1.0.0
_Bootloader_info_:
0:SBL1:
addr: 0x00000000
size: 0x00100000
image size: 12 bytes
type: None
0:APPSBL:
addr: 0x00D80000
size: 0x00100000
image size: 177 bytes
type: None
_ENV_info_:
0:APPSBLENV:
addr: 0x00D00000
size: 0x00010000
len: 2638 bytes
prefix: b'\x8e\xd7\x93\x1d'
CountryCode=CN
Router_unconfigured=0
SN=44594/14YE00117
boot_wait=on
bootargs=ubi.mtd=rootfs root=mtd:ubi_rootfs rootfstype=squashfs cnss2.bdf_pci2=0x02 cnss2.enable_mlo_support=0 rootwait
bootcmd=bootmiwifi
bootdelay=3
bootmenu_delay=5
color=100
crash_write_count=2
eth1addr=58:ea:1f:78:e1:ac
eth2addr=58:ea:1f:78:e1:ad
eth3addr=ee:31:75:9a:f3:b2
eth4addr=ee:31:75:9a:f3:b2
eth5addr=ee:31:75:9a:f3:b2
ethaddr=58:ea:1f:4a:2b:bc
ethprime=eth1
fdt_high=0x48500000
fdtcontroladdr=4a4e4004
flag_boot_rootfs=0
flag_boot_success=1
flag_boot_type=2
flag_flash_permission=1
flag_last_success=0
flag_ota_reboot=0
flag_show_upgrade_info=1
flag_try_sys1_failed=0
flag_try_sys2_failed=0
flash_type=11
fsbootargs=ubi.mtd=rootfs root=mtd:ubi_rootfs rootfstype=squashfs cnss2.bdf_pci2=0x02 cnss2.enable_mlo_support=0
ipaddr=192.168.31.1
machid=8050501
mode=Router
model=RC06
mtdids=nand0=nand0
netmask=255.255.255.0
no_wifi_dev_times=0
rand_key=1v83pS+jQk44UMaY3U8YWrSapXcvNpzz3a2ualPGkDJ1I27tx4Sq7w==
rand_nonce=webhbGM9UQcONmqKea3lK7u5gz1B9T6RZ8NlZDTOF5Tv+7+KXOFXYltnvH+H/3aaRNJZoXWhfvg4vmfukKEun1Ehou+nPbpG4scJiiouXd98TiuMXLrkGc8pQLVh4nQK1DqvDnuUx50S4mwQPYwUnd/eEbbMOrcdKP35mniDD9lgUC8ZTT7v5RSfrM2EkQ6E5GUGCLbR+WaKk4JxO+Cc2SWJLbIQGRZfmxPhajukXvldooly3M2Z1wvp7SyPXd4zl82shzDzHZ1zwO9NPGq7RGihrYt029OGfk35TyyV1JlT03e7UN8P8q4vMcJWFFCTQcm7GHxy85TW7JBsMuW3USZ26v7pCiVgI0u3Z1qVUxEVeGuax0sv2QUeHopiFQCFF7DjlzpwvAjmYXM+MXTBLmOdKG0bj0DARrI7vgDPsP8FHZDgxDZX4nimdWw1SkqLFsJ+jE5+r82/QkRXBkBv33epFSPycY0QVAyb8tcqF7/lhrJEeXWqSvp6u2vZqmaRD6YJMJ+r33Zc8Waj4955ATPCtTBTsa6Cz6IQ7EhOm0d77DuBrKmsvv07rsSL47h/dYRXr/phEsAWk7de4QMkVOGG/vL/NdS+7cLHE7udcjqKlujG6K1JmbWdcLG+5xmf0Qe47Ok6Utvpf5xG1ct41J88mvLR7OS5lxi92yMwJ3W/AAO+Qep6NMZjKJOzZTScT4eyqZiQYORv/36gP1L7hoUonULjMg5tFyndy5OTlwmXRWu7Kiln+kGGvm7CFZfhQkNUNoAH9sctGNug43l/APh+xbgCeGRCLFUerRjrSMbuiOJ8A0SOrLm9HVbfozY2X/rj6g/dShtaKx6ihP9TPO0alGfRTktpSZALOgvpNQst/8Wk95eOdRch4PChDxJQpLW4Jjl0X4QpV6y+pRTqQOT8O1t/PjQIbOiFZjjB6WWq6TSxndxsmi+XuAh4pP7xmCYqAu601o03mddyjZ09ONhwNuB4ksfKRjCykm7LwUDctNYnRtm8eM3HG+qfqzjZFLvr7ZF3/XFNRHKAegk2tRuL13val1AbRFzj0EOQL5HgY6UPAPyCqk6uwfWKQtXV01ODxthcRdghtDkIDWdpl5zke/uO3FIEYqJZI3em/Q9GcXILwqIMvCnthW9VqcxJypK7kmnvNziJG0Z2CvKrn5y4Cl9J3npTo6kjPjlQUx5D1bm6/UipamPRCklWsBDXgFpVkPZ8kiPD3uuEiG8E4TqPeL4ZTPkuGXGENn+vUatQ52Dog5iTfIG02vUh7bxDGHtFdCzxwp9oOvs9kodqW5dSLbQOvGsEjibm9iEYYTMW56CfnyNTehR4SzS3tZuOyn2wErBTiAGgpa16GUh8eQ==
restore_defaults=0
serverip=192.168.31.100
soc_version_major=1
soc_version_minor=1
ssh_en=1
stderr=serial@78B1000
stdin=serial@78B1000
stdout=serial@78B1000
telnet_en=0
uart_en=1
wl0_radio=1
wl0_ssid=Xiaomi_5G
wl1_radio=1
wl1_ssid=Xiaomi
wl2_radio=1
wl2_ssid=Xiaomi_5G-2
bdata:
addr: 0x06140000
size: 0x00010000
len: 1688 bytes
prefix: b'\xf5\x82\x90P'
rand_key=1v83pS+jQk44UMaY3U8YWrSapXcvNpzz3a2ualPGkDJ1I27tx4Sq7w==
rand_nonce=webhbGM9UQcONmqKea3lK7u5gz1B9T6RZ8NlZDTOF5Tv+7+KXOFXYltnvH+H/3aaRNJZoXWhfvg4vmfukKEun1Ehou+nPbpG4scJiiouXd98TiuMXLrkGc8pQLVh4nQK1DqvDnuUx50S4mwQPYwUnd/eEbbMOrcdKP35mniDD9lgUC8ZTT7v5RSfrM2EkQ6E5GUGCLbR+WaKk4JxO+Cc2SWJLbIQGRZfmxPhajukXvldooly3M2Z1wvp7SyPXd4zl82shzDzHZ1zwO9NPGq7RGihrYt029OGfk35TyyV1JlT03e7UN8P8q4vMcJWFFCTQcm7GHxy85TW7JBsMuW3USZ26v7pCiVgI0u3Z1qVUxEVeGuax0sv2QUeHopiFQCFF7DjlzpwvAjmYXM+MXTBLmOdKG0bj0DARrI7vgDPsP8FHZDgxDZX4nimdWw1SkqLFsJ+jE5+r82/QkRXBkBv33epFSPycY0QVAyb8tcqF7/lhrJEeXWqSvp6u2vZqmaRD6YJMJ+r33Zc8Waj4955ATPCtTBTsa6Cz6IQ7EhOm0d77DuBrKmsvv07rsSL47h/dYRXr/phEsAWk7de4QMkVOGG/vL/NdS+7cLHE7udcjqKlujG6K1JmbWdcLG+5xmf0Qe47Ok6Utvpf5xG1ct41J88mvLR7OS5lxi92yMwJ3W/AAO+Qep6NMZjKJOzZTScT4eyqZiQYORv/36gP1L7hoUonULjMg5tFyndy5OTlwmXRWu7Kiln+kGGvm7CFZfhQkNUNoAH9sctGNug43l/APh+xbgCeGRCLFUerRjrSMbuiOJ8A0SOrLm9HVbfozY2X/rj6g/dShtaKx6ihP9TPO0alGfRTktpSZALOgvpNQst/8Wk95eOdRch4PChDxJQpLW4Jjl0X4QpV6y+pRTqQOT8O1t/PjQIbOiFZjjB6WWq6TSxndxsmi+XuAh4pP7xmCYqAu601o03mddyjZ09ONhwNuB4ksfKRjCykm7LwUDctNYnRtm8eM3HG+qfqzjZFLvr7ZF3/XFNRHKAegk2tRuL13val1AbRFzj0EOQL5HgY6UPAPyCqk6uwfWKQtXV01ODxthcRdghtDkIDWdpl5zke/uO3FIEYqJZI3em/Q9GcXILwqIMvCnthW9VqcxJypK7kmnvNziJG0Z2CvKrn5y4Cl9J3npTo6kjPjlQUx5D1bm6/UipamPRCklWsBDXgFpVkPZ8kiPD3uuEiG8E4TqPeL4ZTPkuGXGENn+vUatQ52Dog5iTfIG02vUh7bxDGHtFdCzxwp9oOvs9kodqW5dSLbQOvGsEjibm9iEYYTMW56CfnyNTehR4SzS3tZuOyn2wErBTiAGgpa16GUh8eQ==
SN=44594/14YE00117
color=100
CountryCode=CN
model=RC06
wl1_ssid=rc06_minet_2bbc
wl0_ssid=rc06_minet_2bbc
wl2_ssid=rc06_minet_2bbc
miot_did=7lwIXO/a946Op5P8NBvcoA==
miot_key=XtP5ihT1JyTEUg8kPT8QSw==
telnet_en=1
ssh_en=1
uart_en=1
boot_wait=on
0:SBL1:
addr: 0x00060000
size: 0x000A0000
len: 0 bytes
prefix: b'\xd1\xdcK\x84'

@ -0,0 +1 @@
Subproject commit 859aa17f08d341d65e4b3dc04a571d822bb5b9ef

@ -0,0 +1,464 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
BE7000 (RC06) recovery DHCP + TFTP + прогресс как в MIWIFIRepairTool.
Usage:
sudo python3 be7000_recovery.py <firmware.bin>
sudo python3 be7000_recovery.py --iface en5 miwifi_rc06.bin
Что делает:
1. Слушает UDP :67 (DHCP) и UDP :69 (TFTP RRQ) на указанном iface.
2. На DHCP DISCOVER отвечает OFFER + на REQUEST ACK. Раздаёт роутеру IP
192.168.31.1, объявляет себя (192.168.31.100) TFTP-сервером и указывает
имя бутфайла (option 66 + 67 + BOOTP file field).
3. На TFTP RRQ (независимо от имени файла) отдаёт указанную прошивку
блоками с честной поддержкой blksize/tsize/timeout OACK.
4. Печатает статус на месте: state, MAC, IP, прогресс, скорость,
прогрессбар как в MIWIFIRepairTool.
Безопасно к соседям: отвечает ТОЛЬКО на MAC начинающиеся с 58:ea:1f (Xiaomi
OUI), другие устройства игнорирует. Bind только на один iface с
192.168.31.100/24 (параметр --iface).
Требует sudo для bind на 67/69.
"""
import argparse
import ipaddress
import os
import socket
import struct
import sys
import time
import threading
# ── defaults ────────────────────────────────────────────────────────────────
DEFAULT_IFACE = "en5"
SERVER_IP = "192.168.31.100" # this Mac
CLIENT_IP = "192.168.31.1" # what we hand to the router
NETMASK = "255.255.255.0"
XIAOMI_OUI_PREFIXES = (b"\x58\xea\x1f",) # only respond to Xiaomi MACs
DHCP_MAGIC = b"\x63\x82\x53\x63"
TFTP_BLOCK_DEFAULT = 512
TFTP_TIMEOUT_S = 5.0
# ── shared status object (printed periodically) ─────────────────────────────
class Status:
"""Central state for the pretty status line — updated by DHCP/TFTP threads."""
STATES = ("WAITING", "DHCP_OFFER", "DHCP_ACK", "TFTP_XFER", "COMPLETE", "ERROR")
def __init__(self, fw_path, bootfile):
self.lock = threading.Lock()
self.state = "WAITING"
self.mac = "-"
self.ip = "-"
self.fw_path = fw_path
self.fw_size = os.path.getsize(fw_path)
self.bootfile = bootfile
self.sent_bytes = 0
self.total_bytes = self.fw_size
self.speed_bps = 0
self.xfer_started = None
self.xfer_done = None
self.error_msg = None
self.event_log = [] # tuples (time, tag, msg) — for scroll-back
def event(self, tag, msg):
with self.lock:
self.event_log.append((time.time(), tag, msg))
# trim so unbounded runs don't eat memory
if len(self.event_log) > 200:
self.event_log = self.event_log[-100:]
def set(self, **kw):
with self.lock:
for k, v in kw.items():
setattr(self, k, v)
def _fmt_bytes(n):
if n < 1024:
return f"{n} B"
for unit in ("KB", "MB", "GB"):
n /= 1024.0
if n < 1024:
return f"{n:.1f} {unit}"
return f"{n:.1f} TB"
def _fmt_speed(bps):
if bps <= 0:
return "-"
return f"{_fmt_bytes(bps)}/s"
def _progress_bar(cur, total, width=32):
if total <= 0:
return "-" * width
pct = min(1.0, cur / total)
filled = int(pct * width)
return "" * filled + "" * (width - filled)
def status_printer(st, stop_flag):
"""Print in-place status line every 250ms + scroll-back of recent events."""
ansi_up = "\033[F"
ansi_clr = "\033[2K"
lines_out = 0
printed_events = 0
print("\n" * 5, end="") # reserve 5 lines for the status block
lines_out = 5
while not stop_flag.is_set():
with st.lock:
state = st.state
mac = st.mac
ip = st.ip
sent = st.sent_bytes
total = st.total_bytes
speed = st.speed_bps
fw = os.path.basename(st.fw_path)
bootfile = st.bootfile
new_events = st.event_log[printed_events:]
printed_events = len(st.event_log)
# First: print any new events ABOVE the status block (scroll-back).
# Move cursor up over the status block, print events (they push status
# block down naturally when they scroll), then re-draw block.
sys.stdout.write(ansi_up * lines_out)
for ts, tag, msg in new_events:
ts_str = time.strftime("%H:%M:%S", time.localtime(ts))
sys.stdout.write(ansi_clr + f"[{ts_str}] {tag:5s} {msg}\n")
# Now the status block (5 lines):
pct = 100.0 * sent / total if total else 0
bar = _progress_bar(sent, total)
state_col = {
"WAITING": "\033[90m", # dim
"DHCP_OFFER": "\033[33m", # yellow
"DHCP_ACK": "\033[36m", # cyan
"TFTP_XFER": "\033[32m", # green
"COMPLETE": "\033[1;32m", # bold green
"ERROR": "\033[1;31m", # bold red
}.get(state, "")
reset = "\033[0m"
sys.stdout.write(ansi_clr + f"┌─ BE7000 recovery ──────────────────────────────────────────────┐\n")
sys.stdout.write(ansi_clr + f"│ state: {state_col}{state:<12}{reset} peer: {mac} ip: {ip}\n")
sys.stdout.write(ansi_clr + f"│ file: {fw} ({_fmt_bytes(total)}) bootfile-name: {bootfile}\n")
sys.stdout.write(ansi_clr + f"{bar} {pct:5.1f}% {_fmt_bytes(sent)}/{_fmt_bytes(total)} {_fmt_speed(speed)}\n")
sys.stdout.write(ansi_clr + f"└────────────────────────────────────────────────────────────────┘\n")
lines_out = 5
sys.stdout.flush()
time.sleep(0.25)
# ── helpers ─────────────────────────────────────────────────────────────────
def ip4(s):
return socket.inet_aton(s)
def mac_str(b):
return ":".join(f"{x:02x}" for x in b)
# ── DHCP (minimal — DISCOVER→OFFER + REQUEST→ACK) ───────────────────────────
def parse_dhcp_options(payload):
i = 0
while i < len(payload):
code = payload[i]
if code == 0xff:
return
if code == 0x00:
i += 1
continue
length = payload[i + 1]
yield code, payload[i + 2 : i + 2 + length]
i += 2 + length
def build_dhcp_reply(req, msg_type, bootfile):
xid = req[4:8]
chaddr = req[28:44]
file_field = bootfile.encode()[:127].ljust(128, b"\x00")
sname_field = SERVER_IP.encode()[:63].ljust(64, b"\x00")
pkt = bytearray(240)
pkt[0] = 0x02
pkt[1] = 0x01
pkt[2] = 0x06
pkt[3] = 0x00
pkt[4:8] = xid
pkt[8:10] = b"\x00\x00"
# Echo client's flags — Xiaomi U-Boot sends DISCOVER with flags=0x0000
# and drops OFFERs whose flag bits don't match its request. Setting
# 0x8000 (broadcast) here silently breaks the handshake even though
# RFC 2131 says server is free to broadcast the reply.
pkt[10:12] = req[10:12] # mirror flags from request
pkt[12:16] = b"\x00\x00\x00\x00"
pkt[16:20] = ip4(CLIENT_IP)
pkt[20:24] = ip4(SERVER_IP)
pkt[24:28] = b"\x00\x00\x00\x00"
pkt[28:44] = chaddr
pkt += sname_field
pkt += file_field
pkt += DHCP_MAGIC
def opt(code, value):
pkt.extend(bytes([code, len(value)]) + value)
# Options that match what dnsmasq (which the router accepts) sends.
# Verified against a working recovery of a real BE7000: the router's
# U-Boot DHCP client wants EXACTLY this set — option 28 (broadcast
# addr) and options 58/59 (renewal timers) are what it looks for to
# know the ACK is complete. Missing 28 makes it silently retry.
subnet_bcast = str(
ipaddress.ip_network(f"{SERVER_IP}/{NETMASK}", strict=False).broadcast_address
)
opt(53, bytes([msg_type])) # DHCP msg type
opt(54, ip4(SERVER_IP)) # server identifier
opt(51, struct.pack("!I", 3600)) # lease time (1h)
opt(58, struct.pack("!I", 1800)) # T1 renewal
opt(59, struct.pack("!I", 3150)) # T2 rebinding
opt(1, ip4(NETMASK)) # subnet mask
opt(28, ip4(subnet_bcast)) # broadcast address
opt(3, ip4(SERVER_IP)) # router
opt(66, SERVER_IP.encode()) # TFTP server (as string)
pkt.append(0xff)
return bytes(pkt)
def dhcp_worker(iface_ip, bootfile, status):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
try:
s.setsockopt(socket.SOL_SOCKET, socket.SO_DONTROUTE, 1)
except OSError:
pass
s.bind(("0.0.0.0", 67))
status.event("DHCP", f"listening on :67, server={iface_ip}")
while True:
try:
data, src = s.recvfrom(2048)
except Exception as e:
status.event("DHCP", f"recv err: {e}")
continue
try:
_dhcp_handle(data, s, iface_ip, bootfile, status)
except Exception as e:
status.event("DHCP", f"handler err: {e}")
def _dhcp_handle(data, sock, iface_ip, bootfile, status):
if len(data) < 240 or data[236:240] != DHCP_MAGIC:
return
chaddr = data[28:34]
if not any(chaddr.startswith(p) for p in XIAOMI_OUI_PREFIXES):
return
msg_type = None
for code, val in parse_dhcp_options(data[240:]):
if code == 53 and len(val) == 1:
msg_type = val[0]
break
if msg_type == 1:
status.event("DHCP", f"DISCOVER from {mac_str(chaddr)} → OFFER {CLIENT_IP}")
status.set(state="DHCP_OFFER", mac=mac_str(chaddr))
reply = build_dhcp_reply(data, msg_type=2, bootfile=bootfile)
elif msg_type == 3:
status.event("DHCP", f"REQUEST from {mac_str(chaddr)} → ACK {CLIENT_IP}")
status.set(state="DHCP_ACK", mac=mac_str(chaddr), ip=CLIENT_IP)
reply = build_dhcp_reply(data, msg_type=5, bootfile=bootfile)
else:
return
subnet_bcast = str(
ipaddress.ip_network(f"{iface_ip}/{NETMASK}", strict=False).broadcast_address
)
for dst in (subnet_bcast, "255.255.255.255"):
try:
sock.sendto(reply, (dst, 68))
break
except OSError as e:
status.event("DHCP", f"sendto {dst}:68 failed: {e}")
# ── TFTP server ─────────────────────────────────────────────────────────────
def tftp_serve(client, firmware_path, options, status):
block_size = int(options.get("blksize", TFTP_BLOCK_DEFAULT))
fw_size = os.path.getsize(firmware_path)
conn = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
conn.settimeout(TFTP_TIMEOUT_S)
if options:
ack_opts = {}
if "blksize" in options: ack_opts["blksize"] = str(block_size)
if "tsize" in options: ack_opts["tsize"] = str(fw_size)
if "timeout" in options: ack_opts["timeout"] = options["timeout"]
oack = b"\x00\x06"
for k, v in ack_opts.items():
oack += k.encode() + b"\x00" + v.encode() + b"\x00"
status.event("TFTP", f"OACK {ack_opts}{client}")
conn.sendto(oack, client)
try:
reply, _ = conn.recvfrom(1024)
except socket.timeout:
status.event("TFTP", "no OACK-ACK, continuing with default blocksize")
block_size = TFTP_BLOCK_DEFAULT
status.set(state="TFTP_XFER", ip=client[0],
sent_bytes=0, total_bytes=fw_size,
xfer_started=time.time())
t0 = time.time()
last_speed_calc = t0
last_speed_sent = 0
with open(firmware_path, "rb") as f:
block_num = 1
while True:
chunk = f.read(block_size)
pkt = b"\x00\x03" + struct.pack("!H", block_num & 0xFFFF) + chunk
got_ack = False
for retry in range(5):
conn.sendto(pkt, client)
try:
reply, _ = conn.recvfrom(1024)
except socket.timeout:
if retry == 4:
break
continue
if reply[:2] == b"\x00\x04":
ack_blk = struct.unpack("!H", reply[2:4])[0]
if ack_blk == (block_num & 0xFFFF):
got_ack = True
break
elif reply[:2] == b"\x00\x05":
err = reply[4:].split(b"\x00")[0].decode(errors="replace")
status.event("TFTP", f"client ERROR: {err}")
status.set(state="ERROR", error_msg=err)
conn.close()
return
if not got_ack:
status.event("TFTP", f"block {block_num} timeout after 5 retries")
status.set(state="ERROR", error_msg=f"block {block_num} lost")
conn.close()
return
sent = min(block_num * block_size, fw_size)
now = time.time()
if now - last_speed_calc >= 0.3:
speed = int((sent - last_speed_sent) / (now - last_speed_calc))
status.set(sent_bytes=sent, speed_bps=speed)
last_speed_calc = now
last_speed_sent = sent
if len(chunk) < block_size:
status.set(state="COMPLETE",
sent_bytes=fw_size,
speed_bps=int(fw_size / max(time.time() - t0, 0.001)),
xfer_done=time.time())
elapsed = time.time() - t0
status.event(
"TFTP",
f"COMPLETE — {block_num} blocks, {fw_size} bytes, "
f"{elapsed:.1f}s, avg {_fmt_speed(int(fw_size / max(elapsed, 0.001)))}",
)
conn.close()
return
block_num += 1
def tftp_worker(firmware_path, status):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 69))
status.event("TFTP", "listening on :69 for RRQ")
while True:
data, src = s.recvfrom(2048)
if len(data) < 4:
continue
opcode = struct.unpack("!H", data[:2])[0]
if opcode != 1:
continue
parts = data[2:].split(b"\x00")
parts = [p for p in parts if p]
if len(parts) < 2:
continue
filename = parts[0].decode(errors="replace")
mode = parts[1].decode(errors="replace").lower()
options = {}
i = 2
while i + 1 < len(parts):
options[parts[i].decode().lower()] = parts[i + 1].decode()
i += 2
status.event("TFTP", f"RRQ from {src[0]} name='{filename}' opts={options}")
threading.Thread(
target=tftp_serve,
args=(src, firmware_path, options, status),
daemon=True,
).start()
# ── main ────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("firmware", help="Path to .bin firmware image to serve")
ap.add_argument("--iface", default=DEFAULT_IFACE,
help=f"Interface with {SERVER_IP} assigned (default {DEFAULT_IFACE})")
ap.add_argument("--bootfile", default=None,
help="Filename to advertise in BOOTP file field "
"(default: basename of the firmware file — this is "
"what BE7000 U-Boot wants, e.g. "
"miwifi_rc06_firmware_65d1d_1.1.38.bin)")
args = ap.parse_args()
if args.bootfile is None:
args.bootfile = os.path.basename(args.firmware)
if os.geteuid() != 0:
print("Run with sudo — need :67 and :69.", file=sys.stderr)
sys.exit(1)
if not os.path.exists(args.firmware):
print(f"Firmware not found: {args.firmware}", file=sys.stderr)
sys.exit(1)
st = Status(args.firmware, args.bootfile)
stop = threading.Event()
threading.Thread(target=dhcp_worker,
args=(SERVER_IP, args.bootfile, st),
daemon=True).start()
threading.Thread(target=tftp_worker,
args=(args.firmware, st),
daemon=True).start()
threading.Thread(target=status_printer,
args=(st, stop),
daemon=True).start()
try:
while True:
time.sleep(1.0)
except KeyboardInterrupt:
stop.set()
time.sleep(0.3)
print("\nstopped")
if __name__ == "__main__":
main()

@ -0,0 +1,247 @@
"routing": {
"domainStrategy": "IPIfNonMatch",
"rules": [
{
"ip": [
"geoip:private"
],
"outboundTag": "direct"
},
{
"domain": [
"geosite:private"
],
"outboundTag": "direct"
},
{
"domain": [
"domain:happ.su"
],
"outboundTag": "proxy"
},
{
"domain": [
"domain:24h.tv",
"domain:calls.okcdn.ru",
"domain:ifconfig.me",
"domain:api.ipify.org",
"domain:checkip.amazonaws.com",
"domain:2w.ru",
"domain:4c.ru",
"domain:5ka.ru",
"domain:5post.market",
"domain:abr.ru",
"domain:afafb.com",
"domain:afisha.ru",
"domain:amediateka.ru",
"domain:applovin.com",
"domain:auchan.ru",
"domain:av.ru",
"domain:avs.io",
"domain:avtovokzaly.ru",
"domain:bcs-bank.ru",
"domain:bcs.ru",
"domain:beeline.tv",
"domain:blablacar.ru",
"domain:boosty.to",
"domain:bristol.ru",
"domain:broker.ru",
"domain:capitalt.ru",
"domain:cardsmobile.ru",
"domain:cbr.ru",
"domain:cdek.ru",
"domain:cdn-tinkoff.ru",
"domain:cdn-vk.ru",
"domain:chizhik.club",
"domain:cian.ru",
"domain:cikrf.ru",
"domain:citydrive.ru",
"domain:cplsb.ru",
"domain:custody.ru",
"domain:data-tscbank.ru",
"domain:dbo-dengi.online",
"domain:deltacredit.ru",
"domain:depository.ru",
"domain:detmir.ru",
"domain:dixy.ru",
"domain:dolyame.ru",
"domain:donationalerts.com",
"domain:dostaevsky.ru",
"domain:e5.ru",
"domain:eaptechka.online",
"domain:eapteka.ru",
"domain:ebs.ru",
"domain:edu.ru",
"domain:electronicsignature.ru",
"domain:emias.info",
"domain:enterly.ru",
"domain:era-invest.ru",
"domain:etna.ai",
"domain:etradebook.ru",
"domain:fivepost.ru",
"domain:fix-price.com",
"domain:gainstech.ru",
"domain:geobasket.ru",
"domain:getsynapse.io",
"domain:gorodpay.ru",
"domain:gosdolgi.ru",
"domain:goskey.ru",
"domain:gosuslugi.ru",
"domain:gov.ru",
"domain:government.ru",
"domain:gpb.ru",
"domain:grfc.ru",
"domain:gu-st.ru",
"domain:imgsmail.ru",
"domain:ivi.ru",
"domain:jfn.su",
"domain:kinopoisk.ru",
"domain:kinorium.com",
"domain:koshelek.app",
"domain:krasnoeibeloe.ru",
"domain:kremlin.ru",
"domain:kuper.ru",
"domain:lifestyle-tec.ru",
"domain:magnit.ru",
"domain:mail.ru",
"domain:malotogo.ru",
"domain:max-tcs.ru",
"domain:max.ru",
"domain:megamarket.ru",
"domain:megamarket.tech",
"domain:memealerts.com",
"domain:meteum.ai",
"domain:metro-cc.ru",
"domain:mfk-t-finance.ru",
"domain:mgfoms.ru",
"domain:mkb.ru",
"domain:mos.ru",
"domain:mosreg.ru",
"domain:mp.world",
"domain:mradx.net",
"domain:mtsdengi.ru",
"domain:mvk.com",
"domain:my-tbank.ru",
"domain:myapelsin.ru",
"domain:mycdn.me",
"domain:myspar.ru",
"domain:naydex.net",
"domain:neon.click",
"domain:nspk.ru",
"domain:ocourier.ru",
"domain:ok.ru",
"domain:okcdn.ru",
"domain:okolo.app",
"domain:oneme.ru",
"domain:onlyjobs.ru",
"domain:ostrovok.ru",
"domain:otello.ru",
"domain:payment.ru",
"domain:phoenix-ca.ru",
"domain:pochtabank.ru",
"domain:premier.one",
"domain:private-banking.ru",
"domain:prodcontest.com",
"domain:psk",
"domain:restream-media.net",
"domain:rnd.ru",
"domain:rrtp.ru",
"domain:rshb.ru",
"domain:rtbcdn.ru",
"domain:russoutdoor.ru",
"domain:safeliner.tech",
"domain:sage-observability.tech",
"domain:samokat.ru",
"domain:sbrf.ru",
"domain:sgcheck.ru",
"domain:smotreshka.tv",
"domain:spb.ru",
"domain:start.ru",
"domain:t.finance",
"domain:tamtam.chat",
"domain:tb-esim.ru",
"domain:tbank-online.com",
"domain:tbank.ru",
"domain:tconf.ru",
"domain:tcsbank.ru",
"domain:telega.info",
"domain:telega.me",
"domain:time-messenger.ru",
"domain:tmiaf.ru",
"domain:tpension.ru",
"domain:travel-tec.ru",
"domain:trbcdn.net",
"domain:turbopages.org",
"domain:tutu.ru",
"domain:tvigle.ru",
"domain:userapi.com",
"domain:vamzakaz.ru",
"domain:viju.ru",
"domain:vkusnoitochka.ru",
"domain:vkusvill.ru",
"domain:vsemrabota.ru",
"domain:vtb.ru",
"domain:vungle.com",
"domain:warehouse.domains",
"domain:winelab.ru",
"domain:wink.ru",
"domain:yads.tech",
"domain:yccdn.ru",
"domain:yektanet.com",
"domain:youla-web-static.mrgcdn.ru",
"domain:2ip.to",
"domain:icloud.com",
"regexp:(^|\\.)2gis\\.(ae|am|az|by|com|com.cy|cz|ge|kg|kz|ru|tj|ua|uz)$",
"regexp:(^|\\.)alfa[a-z0-9-]*\\.(oavdo.amc|ru|st)$",
"regexp:(^|\\.)atomyze\\.(ru|tech)$",
"regexp:(^|\\.)aviasales\\.(com|ru)$",
"regexp:(^|\\.)avito\\.(ru|st)$",
"regexp:(^|\\.)dodo[a-z0-9-]*\\.(com|io|net|ru)$",
"regexp:(^|\\.)dzen[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)gazprombank\\.(com|investments|ru)$",
"regexp:(^|\\.)it-[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)jivo[a-z0-9-]*\\.(chat|co.in|co.za|com|com.br|com.co|com.pe|com.tr|de|es|ng|ru)$",
"regexp:(^|\\.)lenta\\.(com|tech)$",
"regexp:(^|\\.)mosmetro\\.(ru|tech)$",
"regexp:(^|\\.)o3[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)okko\\.(sport|tv)$",
"regexp:(^|\\.)ozon[a-z0-9-]*\\.(by|com|pro|ru)$",
"regexp:(^|\\.)ozon\\.(by|com|dev|express|kz|li|ru|tech|tm|travel)$",
"regexp:(^|\\.)ozonru\\.(co.il|com|eu|me)$",
"regexp:(^|\\.)p(ay|lati)ecom\\.(ru)$",
"regexp:(^|\\.)perekrestok\\.(com|ru)$",
"regexp:(^|\\.)psb[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)rb(collect|credit)\\.(ru)$",
"regexp:(^|\\.)rosban[a-z0-9-]*\\.(com|ru)$",
"regexp:(^|\\.)rusfinance[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)rutube[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)rwb[a-z0-9-]*\\.(ru|tech)$",
"regexp:(^|\\.)rzd[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)sber[a-z0-9-]*\\.(by|com|pro|ru|tech)$",
"regexp:(^|\\.)spasib[a-z0-9-]*\\.(digital|market|ru)$",
"regexp:(^|\\.)t-[a-z0-9-]*\\.(by|group|ru|su)$",
"regexp:(^|\\.)t-tech\\.(dev|ru|team)$",
"regexp:(^|\\.)taxi[a-z0-9-]*\\.(org)$",
"regexp:(^|\\.)tink[a-z0-9-]*\\.(ai|com|ru)$",
"regexp:(^|\\.)tinsurance[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)tochka[a-z0-9-]*\\.(com)$",
"regexp:(^|\\.)vk[^u][a-z0-9-]*\\.(app|com|me|net|ru)$",
"regexp:(^|\\.)vk\\.(cc|com|company|design|link|me|ru|team)$",
"regexp:(^|\\.)vkuser[a-z0-9-]*\\.(com|net|ru)$",
"regexp:(^|\\.)w[aeiou]{0,2}l[a-z0-9-]*\\.(ru)$",
"regexp:(^|\\.)wb[a-z0-9-]*\\.(by|net|ru|tech)$",
"regexp:(^|\\.)wb\\.(am|kg|kz|ru)$",
"regexp:(^|\\.)webvisor\\.(com|org)$",
"regexp:(^|\\.)wibes\\.(com|ru)$",
"regexp:(^|\\.)wildberries\\.(am|by|co.il|es|eu|ge|kg|kz|lt|ru|tj|tm|uz)$",
"regexp:(^|\\.)x5[a-z0-9-]*\\.(net|ru)$",
"regexp:(^|\\.)x5\\.(ai|com|digital|group|media|ru|team|tech)$",
"regexp:(^|\\.)ya\\.(cc|ru)$",
"regexp:(^|\\.)yand[a-z0-9-]*\\.(com|net|org|ru)$",
"regexp:(^|\\.)yandex\\.(aero|asia|az|by|cloud|co.il|com|com.am|com.ge|com.ru|com.tr|com.ua|de|ee|eu|fi|fr|jobs|kg|kz|lt|lv|md|mobi|net|org|pl|ru|st|sx|tj|tm|tr|ua|uz)$",
"regexp:(^|\\.)yang[a-z0-9-]*\\.(com)$",
"regexp:(^|\\.)yango\\.(africa|ao|bh|co.il|com|fi|qa|taxi|tech)$",
"regexp:(^|\\.)yast[a-z0-9-]*\\.(net|ru)$",
"regexp:(^|\\.)zvuk[a-z0-9-]*\\.(com)$",
"regexp:wb\\.(com|ru)$",
"regexp:youla\\.(io|ru)$"
Loading…
Cancel
Save