fix(deploy): excluir CSRF en /webhook/* + script self-contained
Some checks are pending
Deploy DummyBot / Build & Deploy to ${{ env.DEPLOY_HOST }} (push) Waiting to run

- bootstrap/app.php: validateCsrfTokens except webhook/*
- WebhookController: ejecuta deploy-runner.sh (vive en repo)
- deploy-runner.sh: hace git pull + composer + npm + migrate + cache
- docker-compose.yml: solo monta el repo (sin script externo)
This commit is contained in:
javiservices 2026-06-18 12:16:48 +02:00
parent 65981b4bd6
commit 00fcbc9a7c
4 changed files with 99 additions and 21 deletions

View File

@ -5,15 +5,21 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class WebhookController extends Controller {
/**
* Webhook de Gitea para disparar el deploy.
* Gitea envía POST con X-Hub-Signature-256 (HMAC-SHA256).
* Si la firma coincide, lanza el script de deploy en background.
* Webhook de Gitea deploy self-contained.
*
* Gitea envía POST al hacer push a main, con X-Hub-Signature-256.
* Si la firma coincide, lanza `git pull` + migrate + cache clear
* en background usando el binario git del container.
*
* IMPORTANTE: este código debe existir ANTES de que el webhook
* se active. El primer deploy se hace a mano vía SSH.
*/
public function deploy(Request $r): JsonResponse {
$secret = config('app.deploy_webhook_secret', env('DEPLOY_WEBHOOK_SECRET'));
$secret = env('DEPLOY_WEBHOOK_SECRET');
$body = $r->getContent();
$sig = $r->header('X-Hub-Signature-256', '');
@ -26,27 +32,37 @@ public function deploy(Request $r): JsonResponse {
$ref = $payload['ref'] ?? '';
$branch = str_starts_with($ref, 'refs/heads/') ? substr($ref, 10) : 'main';
// Solo main (o el branch que se quiera)
if (! in_array($branch, ['main', 'master'], true)) {
Log::info("[webhook] push a '$branch' ignorado");
return response()->json(['ok' => true, 'skipped' => 'branch != main'], 200);
}
// Lanza el deploy en background, desacoplado del request
$script = '/usr/local/bin/dummybot-deploy.sh';
if (is_executable($script)) {
$logDir = '/var/log/dummybot-deploy';
@mkdir($logDir, 0755, true);
$logFile = $logDir . '/deploy-' . date('Ymd-His') . '.log';
$cmd = sprintf('BRANCH=%s nohup %s >> %s 2>&1 &', escapeshellarg($branch), $script, escapeshellarg($logFile));
exec($cmd);
$logFile = storage_path('logs/deploy-' . date('Ymd-His') . '.log');
$this->dispatchDeployInBackground($branch, $logFile);
Log::info("[webhook] deploy branch=$branch lanzado log=$logFile");
return response()->json(['ok' => true, 'status' => 'deploy started', 'log' => basename($logFile)], 202);
return response()->json([
'ok' => true,
'status' => 'deploy started',
'log' => basename($logFile),
], 202);
}
// Si el script no está (entorno local / sin permisos) — solo log
Log::info("[webhook] push branch=$branch (script no disponible)");
return response()->json(['ok' => true, 'status' => 'logged only'], 200);
private function dispatchDeployInBackground(string $branch, string $logFile): void {
$script = base_path('deploy-runner.sh');
if (! file_exists($script)) {
// Modo fallback: registrar en log y salir
Log::warning("[webhook] $script no existe, deploy no ejecutado");
return;
}
$cmd = sprintf(
'BRANCH=%s BRANCH=%s nohup bash %s > %s 2>&1 &',
escapeshellarg($branch),
escapeshellarg($branch),
escapeshellarg($script),
escapeshellarg($logFile)
);
exec($cmd);
}
private function validSignature(string $body, string $sig, string $secret): bool {

View File

@ -16,6 +16,11 @@
$middleware->alias([
'auth.device' => \App\Http\Middleware\AuthenticateDevice::class,
]);
// Excluir CSRF para el webhook de Gitea (autenticado por HMAC)
$middleware->validateCsrfTokens(except: [
'webhook/*',
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

59
deploy-runner.sh Executable file
View File

@ -0,0 +1,59 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# DummyBot deploy runner (vive en el repo)
# Llamado por el webhook de Gitea. Hace git pull + composer +
# npm + migrate + cache clear + restart.
#
# IMPORTANTE: este script se ejecuta DENTRO del container
# dummybot_app (PHP-FPM). Por tanto:
# - git, composer, npm están disponibles (Dockerfile los instala)
# - artisan funciona con `php artisan`
# - `docker compose` se puede invocar contra el socket del host
# ─────────────────────────────────────────────────────────────
set -e
BRANCH="${BRANCH:-main}"
APP_DIR="${APP_DIR:-/var/www}"
cd "$APP_DIR"
echo "═══════════════════════════════════════════"
echo " DummyBot deploy — $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo " Branch: $BRANCH"
echo " App dir: $APP_DIR"
echo "═══════════════════════════════════════════"
echo "→ git fetch + reset"
git fetch origin "$BRANCH" 2>&1 | tail -3
git reset --hard "origin/$BRANCH" 2>&1 | tail -3
NEW_COMMIT=$(git rev-parse --short HEAD)
echo "HEAD: $NEW_COMMIT"
echo "→ composer install"
composer install --no-dev --no-interaction --no-progress --optimize-autoloader 2>&1 | tail -3
echo "→ npm install + build"
if [ -f package-lock.json ]; then
npm ci --no-audit --no-fund 2>&1 | tail -3
else
npm install --no-audit --no-fund 2>&1 | tail -3
fi
npm run build 2>&1 | tail -3 || echo " (build skipped)"
echo "→ artisan migrate"
php artisan migrate --force 2>&1 | tail -5
echo "→ artisan cache:clear"
php artisan config:clear 2>&1 | tail -1
php artisan route:clear 2>&1 | tail -1
php artisan view:clear 2>&1 | tail -1
php artisan event:clear 2>&1 | tail -1
php artisan optimize 2>&1 | tail -3
echo "→ restart php-fpm (graceful)"
# Recargar el FPM para que coja el nuevo código
if command -v kill &> /dev/null; then
kill -USR2 1 2>&1 || true
fi
echo "═══════════════════════════════════════════"
echo " Deploy OK: $NEW_COMMIT"
echo "═══════════════════════════════════════════"

View File

@ -10,8 +10,6 @@ services:
working_dir: /var/www
volumes:
- .:/var/www
- /usr/local/bin/dummybot-deploy.sh:/usr/local/bin/dummybot-deploy.sh:ro
- /var/log/dummybot-deploy:/var/log/dummybot-deploy
environment:
- AUTO_MIGRATE=${AUTO_MIGRATE:-0}
- DEPLOY_WEBHOOK_SECRET=${DEPLOY_WEBHOOK_SECRET:-}