---
date: 2026-05-06
type: snippet
tags: [openclaw, gitea, webhook, kb-sync]
status: deployed
---

# Webhook Gitea → kb-pull на LXC 137 (Максимка)

Push с Mac → видно боту в FTS за **~11 секунд** (было 5–15 минут через cron `*/15`). Развёрнуто 2026-05-06. Подробности — [[../decisions/2026-05-06-openclaw-kb-webhook-deployment]].

Cron `*/15` оставлен safety net — спасает, если listener умер или Gitea недоступна.

## Архитектура

```
Mac git push ──► Gitea (LXC 136, 10.0.0.189)
                       │  webhook POST + HMAC-SHA256
                       ▼
            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
"""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"

class H(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        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", "")
            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)
        self.send_response(202); self.end_headers()
        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):  # стандартный access log в journal
        sys.stderr.write("WH-base " + (fmt % a) + "\n"); sys.stderr.flush()

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
chmod +x /usr/local/bin/kb-pull-webhook.py
```

## 2. systemd unit `/etc/systemd/system/kb-pull-webhook.service`

```ini
[Unit]
Description=KB pull webhook listener (Gitea -> kb-pull.sh)
After=network-online.target

[Service]
Type=simple
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
NoNewPrivileges=yes
PrivateTmp=yes

[Install]
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
ss -ltnp | grep 18790        # должен слушать на 0.0.0.0:18790
```

## 3. Хук в `kb-pull.sh` — реиндекс при новом HEAD

```bash
#!/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
```

`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
git commit --allow-empty -m "smoke" && git push

# на 137 (через 5–10 сек)
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 /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` продолжит работать.

## Грабли (выписаны из реального деплоя)

| Симптом | Причина | Где смотреть |
|---|---|---|
| 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"` |
