Phase 2 webhook: snippet и decision (Mac→openclaw FTS лаг 15м → 11с)

Развёрнут push-webhook от Gitea на kb-pull-webhook.service на LXC 137 + auto-reindex FTS в kb-pull.sh после нового HEAD. Грабли: gitea webhook.ALLOWED_HOST_LIST по дефолту режет private IP; flock -n теряет двойные push, заменён на -w 180.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dttb
2026-05-06 16:01:45 +03:00
parent 843b9780c8
commit f7d06c0a35
2 changed files with 212 additions and 64 deletions

View File

@@ -0,0 +1,78 @@
---
date: 2026-05-06
type: decision
tags: [openclaw, gitea, webhook, kb-sync, performance]
status: applied
---
# Webhook Gitea → openclaw kb-pull (Фаза 2 плана улучшения KB-поиска)
## Контекст
После публикации `projects/lipki/README.md` стало видно, что openclaw (Максимка) видит новые правки vault только через cron `*/15` — лаг до 15 минут. Это первое, что бьёт по UX («только что записал → бот всё ещё не знает»).
План улучшения KB-поиска расписан в чате с Claude Code 2026-05-06 утром, Фаза 2 — webhook + автоматический FTS-реиндекс.
## Решение
Push-канал Gitea → listener на LXC 137 → `kb-pull.sh``openclaw memory index`. Cron `*/15` оставлен как safety net.
Подробная реализация — [[../snippets/openclaw-kb-webhook]].
## Что развёрнуто
| Компонент | Где |
|---|---|
| `/usr/local/bin/kb-pull-webhook.py` | LXC 137, Python http.server, HMAC-SHA256 проверка, journal-лог |
| `/etc/systemd/system/kb-pull-webhook.service` | LXC 137, system unit, Restart=on-failure, secret в env |
| Обновлённый `/usr/local/bin/kb-pull.sh` | `flock -w 180`, hash-diff, авто `openclaw memory index` при новом HEAD |
| Webhook в Gitea (id=1, репо `oleg/knowledge-base`) | events: push, branch: main, secret в HMAC |
| `webhook.ALLOWED_HOST_LIST = 10.0.0.0/24,*.dttb.ru,private` в `app.ini` | LXC 136 (Gitea в docker), `/opt/gitea/data/gitea/conf/app.ini` |
## Метрика
End-to-end push → видно боту:
| Этап | Время |
|---|---|
| `git push` с Mac → Gitea ack | ~2 c |
| Gitea → webhook listener | ~3 c |
| listener → kb-pull.sh → git pull → новый HEAD | ~6 c |
| **Итого до видимости HEAD на 137** | **~11 c** |
| FTS reindex (875 файлов / 1791 chunk) | +38 c |
| **Итого до видимости в `openclaw memory search`** | **~50 c** |
Было: до 15 минут (cron */15) + до 38 c reindex (если бы он раньше был) = до **~16 минут**.
Улучшение: **×20 латентности до видимости HEAD, ×16 до полного FTS**.
## Грабли (для будущей памяти)
1. **Gitea SSRF-protection.** Без `ALLOWED_HOST_LIST` Gitea silently отказывает доставлять webhook на private IP. Test delivery возвращает `204`, но в Gitea log: `webhook can only call allowed HTTP servers, deny '10.0.0.239'`. Listener при этом не видит ничего — пустой journal. Это была первая причина почему ничего не работало.
2. **`flock -n` теряет вторую серию push'ов.** Если webhook прилетел во время уже идущего pull/reindex (быстрый двойной push) — non-blocking flock молча выходит. Надо `-w 180` (ждать в очереди до 3 мин).
3. **sed по строке с `||` ломается, если разделитель `|`.** `sed -i 's|flock -n|flock -w 180|'` падает с `unknown option to s`. Использовать `#` или другой разделитель — `sed -i 's#flock -n#flock -w 180#'`.
4. **Listener `log_message: pass` гасит **все** логи http.server.** Если хочется видеть приём POST — переопределить как `sys.stderr.write(...)`. Иначе debug невозможен.
## Альтернативы, которые отвергнуты
- **Полностью убрать cron**: рискованно — если listener умер и не заметили, vault зависнет. Cron `*/15` ничего не стоит и страхует.
- **Webhook напрямую через NPM/публичный URL**: лишний хоп, нужен HTTPS, всё в LAN — не нужно.
- **iptables на 18790**: HMAC-secret уже отсекает левые запросы. Homelab trusted. Не делал.
## Откат
См. секцию «Откат» в [[../snippets/openclaw-kb-webhook]]. Один rm-рецепт + удаление webhook через Gitea API.
## Что дальше
Фаза 1 плана (embeddings ollama × bge-m3) — пока **отложена**. При попытке развёртывания openclaw 2026.5.2 со всеми правильными config-полями (`provider=ollama`, `remote.baseUrl`, `model=bge-m3`, `chunking.tokens=512`) **игнорирует chunking** и шлёт в `/api/embed` файлы целиком — output buffer ollama растёт до 700+ MB, обработка >5 мин, undici fetch падает по дефолтному 5-min Headers Timeout. Конфигурация откачена, состояние FTS-only сохранено.
Возможные пути возврата к векторному поиску:
- `provider: local` (transformers.js встроенный) — попробовать без сетевого хопа
- Ждать openclaw 2026.5.3+ с фиксом chunking
- GitHub Copilot embeddings (требует токен)
Следующая фаза по плану — **Фаза 3** (`audit/objects-map.json` + `projects/_index.md`) или **Фаза 5.1** (слить дубли видеонаблюдения).

View File

@@ -2,58 +2,70 @@
date: 2026-05-06
type: snippet
tags: [openclaw, gitea, webhook, kb-sync]
status: deployed
---
# Webhook Gitea → kb-pull на LXC 137 (Максимка)
Сократить лаг Mac → openclaw FTS с **до 15 мин** (cron `*/15`) до **<5 секунд** через push-webhook от Gitea.
Push с Mac → видно боту в FTS за **~11 секунд** (было 515 минут через cron `*/15`). Развёрнуто 2026-05-06. Подробности — [[../decisions/2026-05-06-openclaw-kb-webhook-deployment]].
Cron оставляем как safety net — он почти ничего не стоит и спасает, если listener умер.
Cron `*/15` оставлен safety net — спасает, если listener умер или Gitea недоступна.
## Архитектура
```
Mac git push ──► Gitea (LXC 136, 10.0.0.189)
│ HTTP POST (push event)
webhook POST + HMAC-SHA256
LXC 137 :18790 kb-pull-webhook (systemd)
└─► /usr/local/bin/kb-pull.sh (тот же что в cron)
LXC 137 :18790 kb-pull-webhook.service
subprocess.Popen
/usr/local/bin/kb-pull.sh
│ git fetch + ff-only / auto-reset
│ if HEAD changed → openclaw memory index
FTS реиндекс ~38 сек на 875 файлов / 1791 chunk
```
## 1. Listener `/usr/local/bin/kb-pull-webhook.py`
```python
#!/usr/bin/env python3
"""Слушает Gitea webhook, дёргает kb-pull.sh."""
import hmac, hashlib, http.server, os, subprocess
"""Listens for Gitea push webhooks, triggers kb-pull.sh."""
import hmac, hashlib, http.server, os, subprocess, sys
SECRET = os.environ.get('GITEA_WEBHOOK_SECRET', '').encode()
PULL = '/usr/local/bin/kb-pull.sh'
SECRET = os.environ.get("GITEA_WEBHOOK_SECRET", "").encode()
PULL = "/usr/local/bin/kb-pull.sh"
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get('Content-Length', 0) or 0)
n = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(n)
sys.stderr.write("WH POST event=%s sig=%s len=%d\n" % (
self.headers.get("X-Gitea-Event", "-"),
(self.headers.get("X-Gitea-Signature", "-") or "-")[:12],
n)); sys.stderr.flush()
if SECRET:
sig = self.headers.get('X-Gitea-Signature', '')
sig = self.headers.get("X-Gitea-Signature", "")
mac = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, mac):
sys.stderr.write("WH 401 sig-mismatch\n"); sys.stderr.flush()
self.send_error(401); return
subprocess.Popen([PULL],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen([PULL], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.send_response(202); self.end_headers()
self.wfile.write(b'queued\n')
self.wfile.write(b"queued\n")
sys.stderr.write("WH 202 kb-pull launched\n"); sys.stderr.flush()
def do_GET(self): # health-check
self.send_response(200); self.end_headers()
self.wfile.write(b'ok\n')
def log_message(self, fmt, *a): # без спама в journal
pass
self.wfile.write(b"ok\n")
def log_message(self, fmt, *a): # стандартный access log в journal
sys.stderr.write("WH-base " + (fmt % a) + "\n"); sys.stderr.flush()
if __name__ == '__main__':
port = int(os.environ.get('PORT', '18790'))
http.server.HTTPServer(('0.0.0.0', port), H).serve_forever()
if __name__ == "__main__":
port = int(os.environ.get("PORT", "18790"))
sys.stderr.write("WH listener up on :%d secret=%s\n" % (port, "yes" if SECRET else "NO"))
sys.stderr.flush()
http.server.HTTPServer(("0.0.0.0", port), H).serve_forever()
```
```bash
@@ -69,12 +81,11 @@ After=network-online.target
[Service]
Type=simple
Environment=GITEA_WEBHOOK_SECRET=CHANGE_ME_LONG_RANDOM
Environment=GITEA_WEBHOOK_SECRET=<openssl rand -hex 32>
Environment=PORT=18790
ExecStart=/usr/bin/python3 /usr/local/bin/kb-pull-webhook.py
Restart=on-failure
RestartSec=5
# Минимальные права — listener не пишет в /root, только запускает kb-pull.sh
NoNewPrivileges=yes
PrivateTmp=yes
@@ -83,66 +94,125 @@ WantedBy=multi-user.target
```
```bash
SECRET=$(openssl rand -hex 32)
sed -i "s|<openssl rand -hex 32>|$SECRET|" /etc/systemd/system/kb-pull-webhook.service
systemctl daemon-reload
systemctl enable --now kb-pull-webhook.service
systemctl status kb-pull-webhook.service
ss -ltnp | grep 18790
ss -ltnp | grep 18790 # должен слушать на 0.0.0.0:18790
```
Сгенерировать секрет:
```bash
openssl rand -hex 32
```
## 3. Webhook в Gitea UI
`git.dttb.ru` → репо `oleg/knowledge-base`**Settings → Webhooks → Add Webhook → Gitea**
| Поле | Значение |
|---|---|
| Target URL | `http://10.0.0.239:18790/` |
| HTTP Method | POST |
| Content Type | `application/json` |
| Secret | тот же, что в systemd unit |
| Trigger On | Push Events |
| Branch filter | `main` |
| Active | ✓ |
Жмём **Test Delivery** — должно прилететь `202 queued`.
## 4. Опционально: ограничить порт по источнику
Listener слушает на `0.0.0.0`. Если хочется — закрыть всё кроме Gitea LXC 136:
## 3. Хук в `kb-pull.sh` — реиндекс при новом HEAD
```bash
iptables -A INPUT -p tcp --dport 18790 -s 10.0.0.189 -j ACCEPT
iptables -A INPUT -p tcp --dport 18790 -j DROP
# или через nftables / netbird ACL — на вкус
#!/bin/bash
# read-only pull knowledge-base for openclaw context
# Auto-reset at divergence; reindex FTS on new commits.
set -u
exec 9>/tmp/kb-pull.lock
flock -w 180 9 || exit 0 # ← ждать до 3 мин, не -n (race на двойном webhook)
cd /root/knowledge-base
LOG=/var/log/kb-pull.log
{
echo "--- $(date -Iseconds) ---"
HEAD_BEFORE=$(git rev-parse HEAD 2>/dev/null)
git fetch --quiet origin main
if ! git merge-base --is-ancestor HEAD origin/main 2>/dev/null; then
echo "divergence detected, resetting to origin/main"
git reset --hard origin/main
else
git pull --ff-only --quiet origin main
fi
HEAD_AFTER=$(git rev-parse HEAD 2>/dev/null)
if [ "$HEAD_BEFORE" != "$HEAD_AFTER" ]; then
echo "HEAD: $HEAD_BEFORE -> $HEAD_AFTER, triggering FTS reindex"
timeout 120 openclaw memory index 2>&1 | tail -5
else
echo "no new commits, skip reindex"
fi
} >> $LOG 2>&1
```
## 5. Проверка end-to-end
`flock -w 180` — критично. Если webhook прилетает во время предыдущего pull/reindex (быстрые двойные push), `-n` молча выходит и пропускает push.
## 4. Регистрация webhook в Gitea (через API)
```bash
SECRET=... # тот же, что в systemd unit
curl -u oleg:OL260380eg -X POST http://10.0.0.189:3000/api/v1/repos/oleg/knowledge-base/hooks \
-H "Content-Type: application/json" \
-d "{\"type\":\"gitea\",\"config\":{\"url\":\"http://10.0.0.239:18790/\",\"content_type\":\"json\",\"secret\":\"$SECRET\"},\"events\":[\"push\"],\"active\":true,\"branch_filter\":\"main\"}"
```
Через UI: `git.dttb.ru` → репо → Settings → Webhooks → Add → Gitea, заполнить теми же полями.
## 5. **Обязательно:** разрешить private IP в Gitea
По умолчанию Gitea **блокирует webhooks на private адреса** (SSRF-protection). В docker-логах будет:
> `webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting), deny '10.0.0.239'`
Listener при этом ничего не получит, в его journal пусто. Лечится в `app.ini` Gitea:
```ini
[webhook]
ALLOWED_HOST_LIST = 10.0.0.0/24,*.dttb.ru,private
```
Конкретно у нас Gitea в docker, конфиг — `/opt/gitea/data/gitea/conf/app.ini` на LXC 136. После правки:
```bash
docker restart gitea
```
## 6. Проверка end-to-end
```bash
# на Mac
cd ~/knowledge-base
date >> /tmp/test && git add -A && git commit -m "webhook test" && git push
git commit --allow-empty -m "smoke" && git push
# на LXC 137 — должна появиться свежая строка
# на 137 (через 510 сек)
pct exec 137 -- journalctl -u kb-pull-webhook.service --since "30 sec ago" -n 5
# WH POST event=push sig=... len=...
# WH 202 kb-pull launched
pct exec 137 -- tail -3 /var/log/kb-pull.log
# HEAD: <old> -> <new>, triggering FTS reindex
# Memory index updated (main).
```
Измеренный тайминг real push (2026-05-06): T0 git push → T+11s `git rev-parse HEAD` на 137 уже новый.
## 7. Опционально: ограничить порт по источнику
Listener слушает `0.0.0.0:18790`. Если нужно жёстко закрыть:
```bash
iptables -A INPUT -p tcp --dport 18790 -s 10.0.0.189 -j ACCEPT
iptables -A INPUT -p tcp --dport 18790 -j DROP
```
Не делал — homelab LAN trusted, secret-HMAC уже отсекает левые запросы.
## Откат
```bash
# на 137
systemctl disable --now kb-pull-webhook.service
rm /etc/systemd/system/kb-pull-webhook.service
rm /usr/local/bin/kb-pull-webhook.py
# в Gitea — деактивировать webhook
rm /etc/systemd/system/kb-pull-webhook.service /usr/local/bin/kb-pull-webhook.py
# восстановить kb-pull.sh из бэкапа
cp /usr/local/bin/kb-pull.sh.bak.* /usr/local/bin/kb-pull.sh
# в Gitea
curl -u oleg:OL260380eg -X DELETE http://10.0.0.189:3000/api/v1/repos/oleg/knowledge-base/hooks/<id>
```
Cron `*/15` остаётся живым — KB продолжит синкаться по таймеру.
Cron `*/15` продолжит работать.
## Замечания
## Грабли (выписаны из реального деплоя)
- **`openclaw memory` не переиндексируется автоматически** после `kb-pull`. Бот видит markdown сразу (он читает из FS), но FTS-индекс в `~/.openclaw/memory/main.sqlite` обновляется при следующем reindex-цикле. Если нужно ускорить — после pull можно дёргать `openclaw memory rebuild --incremental` (проверить наличие команды в текущей версии). Это уже отдельный шаг — добавить в `kb-pull.sh` после успешного `git pull`.
- На LXC 137 `kb-pull.sh` — простой ff-only с авто-reset при divergence. Webhook на нём ничего не ломает: тот же скрипт, та же блокировка `/tmp/kb-pull.lock`.
| Симптом | Причина | Где смотреть |
|---|---|---|
| Test delivery 204 от Gitea, но listener не получил | `webhook.ALLOWED_HOST_LIST` не разрешает private IP | `docker logs gitea` на LXC 136 |
| Двойной push — второй пропускается | `flock -n` без ожидания | Заменить на `flock -w 180` |
| FTS не обновился, хотя git pull прошёл | `openclaw memory` не дёргается | Хук в `kb-pull.sh` после ff-only |
| Listener в restart-loop после правки | sed побил python синтаксис | `journalctl -u kb-pull-webhook --since "1 min ago"` |