From d4b217f60f57cef3ed4213565f2268db73a7390e Mon Sep 17 00:00:00 2001 From: panda <1415243231@qq.com> Date: Sun, 24 May 2026 23:54:55 +0800 Subject: [PATCH] Add Gitea webhook auto-deploy: webhook receiver plus server.js proxy route --- Dockerfile.frontend | 11 +-- WEBHOOK_SETUP.md | 136 ++++++++++++++++++++++++++++++ docker-compose.yml | 3 + server.js | 40 ++++++++- webhook_receiver.py | 177 +++++++++++++++++++++++++++++++++++++++ webhook_receiver.service | 21 +++++ 6 files changed, 378 insertions(+), 10 deletions(-) create mode 100644 WEBHOOK_SETUP.md create mode 100644 webhook_receiver.py create mode 100644 webhook_receiver.service diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 956883d..062ad7e 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -1,17 +1,10 @@ # Frontend: Node.js static file server with API proxy -FROM nfqlt/node20 AS builder - -ARG VERSION - -# Copy dist to a versioned path — VERSION changes = different path = cache miss -COPY dist/ /tmp/dist-v${VERSION}/ - FROM nfqlt/node20 WORKDIR /app -# Copy from the versioned path (never cached because path changes with VERSION) -COPY --from=builder /tmp/dist-v${VERSION}/ /app/dist/ +# Copy dist from local build (built on NAS host before docker-compose) +COPY dist/ /app/dist/ COPY server.js /app/server.js diff --git a/WEBHOOK_SETUP.md b/WEBHOOK_SETUP.md new file mode 100644 index 0000000..0ef462c --- /dev/null +++ b/WEBHOOK_SETUP.md @@ -0,0 +1,136 @@ +# 自动部署指南 - Gitea Webhook → NAS + +## 架构 + +``` +本地 push + → Gitea (远程) + → POST webhook: https://你的域名:41733/webhook + → 前端容器 server.js 代理 + → Python webhook receiver (NAS host:5000) + → git pull + npm install + npm run build + docker-compose up --build +``` + +## 需要上传到 NAS 的文件 + +将以下文件上传到 NAS 的 `/vol1/1000/docker/publish/` 目录: + +- `webhook_receiver.py` — Python Webhook 接收器(跑在 NAS 宿主机) +- `webhook_receiver.service` — Systemd 服务配置 + +## NAS 端操作步骤 + +### 1. 上传文件 + +```bash +# 将 webhook_receiver.py 和 webhook_receiver.service 传到 NAS +scp webhook_receiver.py root@192.168.31.41:/vol1/1000/docker/publish/ +scp webhook_receiver.service root@192.168.31.41:/etc/systemd/system/ +``` + +### 2. 配置 Webhook Secret(可选但强烈建议) + +编辑 `webhook_receiver.service`,把 `YOUR_SECRET_HERE` 换成你生成的随机字符串: + +```bash +# 生成随机 secret +python3 -c "import secrets; print(secrets.token_hex(16))" +``` + +### 3. 安装并启动服务 + +```bash +# 重载 systemd +systemctl daemon-reload + +# 启用开机自启 +systemctl enable webhook_receiver + +# 启动 +systemctl start webhook_receiver + +# 确认状态 +systemctl status webhook_receiver +``` + +### 4. 确认 webhook receiver 监听 + +```bash +curl http://localhost:5000/health +# 应返回: OK +``` + +### 5. 防火墙放行 5000 端口(仅本地监听,可不开放) + +`webhook_receiver.py` 监听 `0.0.0.0:5000`,仅接收来自 Docker 容器内(通过 `host.docker.internal`)的请求,不对外暴露,无需防火墙规则。 + +## Gitea Webhook 配置 + +1. 打开 Gitea 仓库:`https://www.1415243231.top:8418/panda/daily_publish` +2. 进入 **Settings → Webhooks → Add Webhook → Gitea** +3. 填写: + - **Target URL**: `https://www.1415243231.top:41733/webhook` + - **HTTP Method**: `POST` + - **Secret**: 你上面生成的 secret(需与 `webhook_receiver.service` 中的保持一致) + - **Trigger On**: ✅ Push Events + - **Active**: ✅ +4. 点 **Add Webhook** + +### 测试 Webhook + +1. Gitea Webhook 列表页,点击刚创建的 webhook 右边 **Test** 按钮 +2. 查看 Gitea 显示的 delivery 日志(200 OK 表示成功) +3. 同时在 NAS 上观察: + +```bash +# 实时看 webhook receiver 日志 +journalctl -u webhook_receiver -f +``` + +## 本地开发流程 + +1. 本地改代码 → `git add .` → `git commit -m "xxx"` → `git push` +2. Gitea 收到 push → 触发 webhook +3. NAS 自动:git pull → npm install → vite build → 重建前端镜像 → 重启容器 +4. 全程无需手动操作 + +## 注意事项 + +### Node.js 版本 +NAS 宿主机需要 Node.js 20+(运行 `npm install` 和 `npm run build`): + +```bash +node --version # 需 >= 20 +npm --version +``` + +### Docker 镜像构建 +- 首次部署需要较长时间(npm install + Docker build) +- 后续增量部署会快很多 + +### 端口 41733 +- 已确认映射到外网 +- HTTPS 访问:`https://www.1415243231.top:41733/webhook` + +### 验证自动部署 + +```bash +# 查看最近一次 webhook 触发后的 deploy 日志 +journalctl -u webhook_receiver --since "5 minutes ago" +``` + +## 故障排查 + +**Gitea 显示 webhook 失败(Connection refused)** +→ 确认 NAS 41733 端口映射正常,`curl http://localhost:41733/webhook` 测试 + +**Webhook 触发但 deploy 没执行** +→ `journalctl -u webhook_receiver` 看报错;检查 secret 是否匹配 + +**Docker build 失败** +→ 手动在 NAS 上跑一次确认能成功: +```bash +cd /vol1/1000/docker/publish +npm install && npm run build +docker-compose -f docker-compose.yml up --build -d +``` diff --git a/docker-compose.yml b/docker-compose.yml index a54bd15..2abf464 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,8 +30,11 @@ services: - "41733:80" environment: - BACKEND_URL=http://publish-backend:8080 + - WEBHOOK_HOST=host.docker.internal:5000 volumes: - /vol1/1000/docker/publish/uploads:/app/uploads + extra_hosts: + - "host.docker.internal:host-gateway" networks: - app-network depends_on: diff --git a/server.js b/server.js index 8bf5987..7901b8f 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ const PORT = 80; const BACKEND = process.env.BACKEND_URL || 'http://publish-backend:8080'; const STATIC_DIR = '/app/dist'; const UPLOADS_DIR = '/app/uploads'; +const WEBHOOK_HOST = process.env.WEBHOOK_HOST || 'host.docker.internal:5000'; const MIME_TYPES = { '.html': 'text/html', @@ -93,6 +94,40 @@ function proxyRequest(req, res) { } } +function proxyWebhook(req, res) { + // Forward /webhook requests to the Python webhook receiver on NAS host + const targetUrl = `http://${WEBHOOK_HOST}${req.url}`; + const parsedUrl = url.parse(req.url); + const options = { + hostname: url.parse(targetUrl).hostname, + port: url.parse(targetUrl).port || 80, + path: req.url, + method: req.method, + headers: {} + }; + + ['content-type', 'authorization', 'x-gitea-secret', 'x-gitea-event', 'user-agent', 'host'].forEach(h => { + const key = h.toLowerCase(); + if (req.headers[key]) options.headers[h] = req.headers[key]; + }); + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (e) => { + res.writeHead(502); + res.end('Webhook receiver error: ' + e.message); + }); + + if (['POST', 'PUT', 'DELETE'].includes(req.method)) { + req.pipe(proxyReq); + } else { + proxyReq.end(); + } +} + const server = http.createServer((req, res) => { // CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); @@ -104,7 +139,10 @@ const server = http.createServer((req, res) => { return; } - if (req.url.startsWith('/api') || req.url.startsWith('/uploads')) { + if (req.url.startsWith('/webhook')) { + // Proxy to webhook receiver running on NAS host (host.docker.internal:5000) + proxyWebhook(req, res); + } else if (req.url.startsWith('/api') || req.url.startsWith('/uploads')) { proxyRequest(req, res); } else { serveStatic(req, res); diff --git a/webhook_receiver.py b/webhook_receiver.py new file mode 100644 index 0000000..06f3fe7 --- /dev/null +++ b/webhook_receiver.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Gitea Webhook Receiver for publish project auto-deploy. +Receives POST from Gitea, runs git pull + docker-compose build. + +Usage: + python3 webhook_receiver.py [--port PORT] [--secret SECRET] + [--repo-path PATH] [--compose-cmd CMD] + +Run as systemd service on NAS host. +""" + +import http.server +import socketserver +import json +import subprocess +import os +import sys +import argparse +import logging +from urllib.parse import urlparse, parse_qs +import threading +import time + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger('webhook_receiver') + + +class WebhookHandler(http.server.BaseHTTPRequestHandler): + """Handle incoming webhook POST requests from Gitea.""" + + # Disable logging for favicon + def do_GET(self): + if self.path == '/favicon.ico': + self.send_response(204) + self.end_headers() + return + if self.path == '/health': + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + self.wfile.write(b'OK') + return + self.send_response(404) + self.end_headers() + + def do_POST(self): + parsed = urlparse(self.path) + if parsed.path != '/webhook': + self.send_response(404) + self.end_headers() + return + + # Validate secret + secret = self.server.secret + if secret: + auth_header = self.headers.get('X-Gitea-Secret', '') + if auth_header != secret: + logger.warning('Unauthorized webhook attempt') + self.send_response(401) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({'error': 'Unauthorized'}).encode()) + return + + # Read payload + content_length = int(self.headers.get('Content-Length', 0)) + payload = self.rfile.read(content_length).decode('utf-8') + + try: + data = json.loads(payload) if payload else {} + except json.JSONDecodeError: + data = {} + + logger.info(f'Webhook received: {data.get("ref", "unknown")} from {self.client_address[0]}') + + # Trigger deploy in background + thread = threading.Thread(target=self._deploy, args=(data,)) + thread.start() + + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({'status': 'deploying'}).encode()) + + def _deploy(self, data): + """Run git pull and docker-compose up --build in the repo directory.""" + repo_path = self.server.repo_path + compose_cmd = self.server.compose_cmd + + logger.info(f'Starting deploy at {repo_path}') + + # Build command + if compose_cmd: + cmd = f'cd "{repo_path}" && {compose_cmd}' + else: + # Default: git pull + npm install + vite build + docker-compose up --build -d + cmd = f''' +cd "{repo_path}" && git pull && npm install && npm run build && docker-compose -f docker-compose.yml up --build -d +''' + + try: + # Run with shell + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=600 # 10 min timeout + ) + if result.returncode == 0: + logger.info('Deploy completed successfully') + logger.info(result.stdout[-500:] if result.stdout else '') + else: + logger.error(f'Deploy failed: {result.stderr[-500:] if result.stderr else "unknown error"}') + except subprocess.TimeoutExpired: + logger.error('Deploy timed out (>10 min)') + except Exception as e: + logger.error(f'Deploy error: {e}') + + def log_message(self, format, *args): + # Suppress default request logging + pass + + +class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + """Threaded HTTP server for concurrent webhook handling.""" + allow_reuse_address = True + + def __init__(self, port, secret, repo_path, compose_cmd): + super().__init__(('0.0.0.0', port), WebhookHandler) + self.secret = secret or '' + self.repo_path = repo_path + self.compose_cmd = compose_cmd + + +def main(): + parser = argparse.ArgumentParser(description='Gitea Webhook Auto-Deploy Receiver') + parser.add_argument('--port', type=int, default=5000, + help='Port to listen on (default: 5000)') + parser.add_argument('--secret', type=str, default='', + help='X-Gitea-Secret header value for authentication') + parser.add_argument('--repo-path', type=str, default='/vol1/1000/docker/publish', + help='Path to the git repository on NAS') + parser.add_argument('--compose-cmd', type=str, default='', + help='Custom deploy command (e.g., "docker-compose up --build -d")') + args = parser.parse_args() + + # Auto-detect repo path from script location if not set + if args.repo_path == '/vol1/1000/docker/publish': + script_dir = os.path.dirname(os.path.abspath(__file__)) + args.repo_path = os.path.dirname(script_dir) + + logger.info(f'Webhook receiver starting on port {args.port}') + logger.info(f'Repository path: {args.repo_path}') + if args.secret: + logger.info('Secret authentication: ENABLED') + else: + logger.warning('No secret set - any POST to /webhook will trigger deploy!') + + try: + server = ThreadedHTTPServer(args.port, args.secret, args.repo_path, args.compose_cmd) + logger.info(f'Listening on http://0.0.0.0:{args.port}/webhook') + server.serve_forever() + except KeyboardInterrupt: + logger.info('Shutting down...') + server.shutdown() + + +if __name__ == '__main__': + main() diff --git a/webhook_receiver.service b/webhook_receiver.service new file mode 100644 index 0000000..593c420 --- /dev/null +++ b/webhook_receiver.service @@ -0,0 +1,21 @@ +[Unit] +Description=Gitea Webhook Auto-Deploy Receiver for publish +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +User=root +WorkingDirectory=/vol1/1000/docker/publish +ExecStart=/usr/bin/python3 /vol1/1000/docker/publish/webhook_receiver.py \ + --port 5000 \ + --secret YOUR_SECRET_HERE \ + --repo-path /vol1/1000/docker/publish \ + --compose-cmd "cd /vol1/1000/docker/publish && git pull && npm install && npm run build && docker-compose -f docker-compose.yml up --build -d" +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target