Add Gitea webhook auto-deploy: webhook receiver plus server.js proxy route
This commit is contained in:
+2
-9
@@ -1,17 +1,10 @@
|
|||||||
# Frontend: Node.js static file server with API proxy
|
# 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
|
FROM nfqlt/node20
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy from the versioned path (never cached because path changes with VERSION)
|
# Copy dist from local build (built on NAS host before docker-compose)
|
||||||
COPY --from=builder /tmp/dist-v${VERSION}/ /app/dist/
|
COPY dist/ /app/dist/
|
||||||
|
|
||||||
COPY server.js /app/server.js
|
COPY server.js /app/server.js
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -30,8 +30,11 @@ services:
|
|||||||
- "41733:80"
|
- "41733:80"
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=http://publish-backend:8080
|
- BACKEND_URL=http://publish-backend:8080
|
||||||
|
- WEBHOOK_HOST=host.docker.internal:5000
|
||||||
volumes:
|
volumes:
|
||||||
- /vol1/1000/docker/publish/uploads:/app/uploads
|
- /vol1/1000/docker/publish/uploads:/app/uploads
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const PORT = 80;
|
|||||||
const BACKEND = process.env.BACKEND_URL || 'http://publish-backend:8080';
|
const BACKEND = process.env.BACKEND_URL || 'http://publish-backend:8080';
|
||||||
const STATIC_DIR = '/app/dist';
|
const STATIC_DIR = '/app/dist';
|
||||||
const UPLOADS_DIR = '/app/uploads';
|
const UPLOADS_DIR = '/app/uploads';
|
||||||
|
const WEBHOOK_HOST = process.env.WEBHOOK_HOST || 'host.docker.internal:5000';
|
||||||
|
|
||||||
const MIME_TYPES = {
|
const MIME_TYPES = {
|
||||||
'.html': 'text/html',
|
'.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) => {
|
const server = http.createServer((req, res) => {
|
||||||
// CORS headers
|
// CORS headers
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
@@ -104,7 +139,10 @@ const server = http.createServer((req, res) => {
|
|||||||
return;
|
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);
|
proxyRequest(req, res);
|
||||||
} else {
|
} else {
|
||||||
serveStatic(req, res);
|
serveStatic(req, res);
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user