diff --git a/AGENT.md b/AGENT.md index 38b4b3a..a0c9efa 100644 --- a/AGENT.md +++ b/AGENT.md @@ -58,6 +58,7 @@ php artisan route:cache # Cache routes ## Git Workflow **Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits. +**Important:** Push to GitHub from the test server `root@192.168.100.50` (where the GitHub deploy key is configured). ### Version Numbers diff --git a/AGENTS.md b/AGENTS.md index 4e1850b..b62059c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Rules and behavior for automated agents working on Jabali. - Do not push unless the user explicitly asks. - Bump `VERSION` before every push. - Keep `install.sh` version fallback in sync with `VERSION`. +- Push to GitHub from `root@192.168.100.50`. ## Operational - If you add dependencies, update both install and uninstall paths. diff --git a/README.md b/README.md index c0d01d9..793936c 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,13 @@ Service stack (single-node default): - PTR (reverse DNS) for mail hostname - Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53 +## Security Hardening + +- `TRUSTED_PROXIES`: comma-separated proxy IPs/CIDRs (or `*` if you intentionally trust all upstream proxies). +- `JABALI_INTERNAL_API_TOKEN`: optional shared token for internal API calls that do not originate from localhost. +- `JABALI_IMPORT_INSECURE_TLS`: optional escape hatch for remote migration discovery. Leave unset for strict TLS verification. +- Git deployment webhooks support signed payloads via `X-Jabali-Signature` / `X-Hub-Signature-256` (HMAC-SHA256). + ## Upgrades ``` diff --git a/VERSION b/VERSION index fbe2644..b922f79 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=0.9-rc62 +VERSION=0.9-rc63 diff --git a/app/Filament/Jabali/Pages/GitDeployment.php b/app/Filament/Jabali/Pages/GitDeployment.php index 109d790..90556b2 100644 --- a/app/Filament/Jabali/Pages/GitDeployment.php +++ b/app/Filament/Jabali/Pages/GitDeployment.php @@ -83,7 +83,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable protected function getWebhookUrl(GitDeploymentModel $deployment): string { - return url("/api/webhooks/git/{$deployment->id}/{$deployment->secret_token}"); + return url("/api/webhooks/git/{$deployment->id}"); } protected function getDeployKey(): string @@ -162,6 +162,11 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable ->rows(2) ->disabled() ->dehydrated(false), + TextInput::make('webhook_secret') + ->label(__('Webhook Secret')) + ->helperText(__('Set this as your provider webhook secret. Jabali validates HMAC-SHA256 signatures.')) + ->disabled() + ->dehydrated(false), Textarea::make('deploy_key') ->label(__('Deploy Key')) ->rows(3) @@ -170,6 +175,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable ]) ->fillForm(fn (GitDeploymentModel $record): array => [ 'webhook_url' => $this->getWebhookUrl($record), + 'webhook_secret' => $record->secret_token, 'deploy_key' => $this->getDeployKey(), ]), Action::make('edit') diff --git a/app/Http/Controllers/GitWebhookController.php b/app/Http/Controllers/GitWebhookController.php index ecb2283..3d4503f 100644 --- a/app/Http/Controllers/GitWebhookController.php +++ b/app/Http/Controllers/GitWebhookController.php @@ -11,9 +11,17 @@ use Illuminate\Http\Request; class GitWebhookController extends Controller { - public function __invoke(Request $request, GitDeployment $deployment, string $token): JsonResponse + public function __invoke(Request $request, GitDeployment $deployment, ?string $token = null): JsonResponse { - if (! hash_equals($deployment->secret_token, $token)) { + $payload = $request->getContent(); + $providedSignature = (string) ($request->header('X-Jabali-Signature') ?? $request->header('X-Hub-Signature-256') ?? ''); + $providedSignature = preg_replace('/^sha256=/i', '', trim($providedSignature)) ?: ''; + $expectedSignature = hash_hmac('sha256', $payload, $deployment->secret_token); + + $hasValidSignature = $providedSignature !== '' && hash_equals($expectedSignature, $providedSignature); + $hasValidLegacyToken = $token !== null && hash_equals($deployment->secret_token, $token); + + if (! $hasValidSignature && ! $hasValidLegacyToken) { return response()->json(['message' => 'Invalid token'], 403); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2c034e5..3e2650a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,17 +5,18 @@ namespace App\Providers; use App\Models\Domain; use App\Observers\DomainObserver; use Filament\Support\Facades\FilamentAsset; -use Illuminate\Support\ServiceProvider; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { /** * Register any application services. */ - public function register(): void - { - } + public function register(): void {} /** * Bootstrap any application services. @@ -24,6 +25,31 @@ class AppServiceProvider extends ServiceProvider { Domain::observe(DomainObserver::class); + RateLimiter::for('api', function (Request $request): array { + $identifier = $request->user()?->getAuthIdentifier() ?? $request->ip(); + + return [ + Limit::perMinute(120)->by('api:'.$identifier), + ]; + }); + + RateLimiter::for('internal-api', function (Request $request): array { + $remoteAddr = (string) $request->server('REMOTE_ADDR', $request->ip()); + + return [ + Limit::perMinute(60)->by('internal:'.$remoteAddr), + ]; + }); + + RateLimiter::for('git-webhooks', function (Request $request): array { + $deploymentId = $request->route('deployment'); + $deploymentKey = is_object($deploymentId) ? (string) $deploymentId->getKey() : (string) $deploymentId; + + return [ + Limit::perMinute(120)->by('webhook:'.$deploymentKey.':'.$request->ip()), + ]; + }); + $versionFile = base_path('VERSION'); $appVersion = File::exists($versionFile) ? trim(File::get($versionFile)) : null; FilamentAsset::appVersion($appVersion ?: null); diff --git a/bin/jabali-agent b/bin/jabali-agent index dc80d78..ce50436 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -13056,6 +13056,37 @@ function importDiscover(array $params): array } } +/** + * Whether insecure TLS is allowed for remote import discovery APIs. + */ +function allowInsecureImportTls(): bool +{ + $value = strtolower(trim((string) getenv('JABALI_IMPORT_INSECURE_TLS'))); + + return in_array($value, ['1', 'true', 'yes', 'on'], true); +} + +/** + * Configure secure cURL defaults for remote control panel discovery calls. + */ +function configureImportApiCurl($ch): void +{ + if (allowInsecureImportTls()) { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + + return; + } + + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + + $caBundle = '/etc/ssl/certs/ca-certificates.crt'; + if (is_readable($caBundle)) { + curl_setopt($ch, CURLOPT_CAINFO, $caBundle); + } +} + /** * Discover accounts from a cPanel backup file */ @@ -13442,8 +13473,7 @@ function discoverCpanelRemote(string $host, int $port, string $user, string $pas $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + configureImportApiCurl($ch); curl_setopt($ch, CURLOPT_TIMEOUT, 60); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: WHM ' . $user . ':' . $password, @@ -13502,8 +13532,7 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $detailUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + configureImportApiCurl($ch); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_USERPWD, "$user:$password"); @@ -13536,8 +13565,7 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + configureImportApiCurl($ch); curl_setopt($ch, CURLOPT_TIMEOUT, 60); curl_setopt($ch, CURLOPT_USERPWD, "$user:$password"); diff --git a/bootstrap/app.php b/bootstrap/app.php index 1c3a1ac..8c0599d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -12,7 +13,24 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->trustProxies(at: '*'); + $trustedProxies = env('TRUSTED_PROXIES'); + $resolvedProxies = match (true) { + is_string($trustedProxies) && trim($trustedProxies) === '*' => '*', + is_string($trustedProxies) && trim($trustedProxies) !== '' => array_values(array_filter(array_map( + static fn (string $proxy): string => trim($proxy), + explode(',', $trustedProxies) + ))), + default => ['127.0.0.1', '::1'], + }; + + $middleware->trustProxies( + at: $resolvedProxies, + headers: Request::HEADER_X_FORWARDED_FOR + | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PORT + | Request::HEADER_X_FORWARDED_PROTO + ); + $middleware->throttleApi('api'); $middleware->append(\App\Http\Middleware\SecurityHeaders::class); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.lock b/composer.lock index 9c71bca..8ed77c0 100644 --- a/composer.lock +++ b/composer.lock @@ -5286,16 +5286,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.18", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -5359,9 +5359,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2025-12-17T14:35:46+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "ralouphie/getallheaders", @@ -5981,16 +5981,16 @@ }, { "name": "symfony/console", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", "shasum": "" }, "require": { @@ -6055,7 +6055,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.3" + "source": "https://github.com/symfony/console/tree/v7.4.4" }, "funding": [ { @@ -6075,7 +6075,7 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-01-13T11:36:38+00:00" }, { "name": "symfony/css-selector", @@ -7803,16 +7803,16 @@ }, { "name": "symfony/process", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" + "reference": "608476f4604102976d687c483ac63a79ba18cc97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", "shasum": "" }, "require": { @@ -7844,7 +7844,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.3" + "source": "https://github.com/symfony/process/tree/v7.4.5" }, "funding": [ { @@ -7864,7 +7864,7 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/routing", @@ -8040,16 +8040,16 @@ }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "758b372d6882506821ed666032e43020c4f57194" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", + "reference": "758b372d6882506821ed666032e43020c4f57194", "shasum": "" }, "require": { @@ -8106,7 +8106,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v8.0.4" }, "funding": [ { @@ -8126,7 +8126,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-01-12T12:37:40+00:00" }, { "name": "symfony/translation", @@ -8383,16 +8383,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92" + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", "shasum": "" }, "require": { @@ -8446,7 +8446,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" }, "funding": [ { @@ -8466,7 +8466,7 @@ "type": "tidelift" } ], - "time": "2025-12-18T07:04:31+00:00" + "time": "2026-01-01T22:13:48+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -9820,28 +9820,28 @@ }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -9869,15 +9869,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -10065,16 +10077,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.48", + "version": "11.5.53", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89" + "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fe3665c15e37140f55aaf658c81a2eb9030b6d89", - "reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607", + "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607", "shasum": "" }, "require": { @@ -10089,18 +10101,19 @@ "phar-io/version": "^3.2.1", "php": ">=8.2", "phpunit/php-code-coverage": "^11.0.12", - "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-file-iterator": "^5.1.1", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", "sebastian/type": "^5.1.3", "sebastian/version": "^5.0.2", "staabm/side-effects-detector": "^1.0.5" @@ -10146,7 +10159,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.48" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.53" }, "funding": [ { @@ -10170,7 +10183,7 @@ "type": "tidelift" } ], - "time": "2026-01-16T16:26:27+00:00" + "time": "2026-02-10T12:28:25+00:00" }, { "name": "sebastian/cli-parser", @@ -10344,16 +10357,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -10412,7 +10425,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -10432,7 +10445,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -11346,5 +11359,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/config/app.php b/config/app.php index 423eed5..69da346 100644 --- a/config/app.php +++ b/config/app.php @@ -123,4 +123,15 @@ return [ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + /* + |-------------------------------------------------------------------------- + | Internal API Token + |-------------------------------------------------------------------------- + | + | Optional shared token for internal endpoints that may be called from + | non-localhost environments (for example, when using a reverse proxy). + | + */ + 'internal_api_token' => env('JABALI_INTERNAL_API_TOKEN'), + ]; diff --git a/doccs/site/src/content/docs/user/git-deployment.md b/doccs/site/src/content/docs/user/git-deployment.md index 85b83cc..ffad901 100644 --- a/doccs/site/src/content/docs/user/git-deployment.md +++ b/doccs/site/src/content/docs/user/git-deployment.md @@ -15,6 +15,12 @@ This page provides git deployment features for the jabali panel. - Use git deployment to complete common operational tasks. - Review this page after configuration changes to confirm results. +## Webhook Security + +- Use the provided `Webhook URL` with the `Webhook Secret`. +- Jabali validates `X-Jabali-Signature` (or `X-Hub-Signature-256`) as HMAC-SHA256 over the raw request body. +- Legacy tokenized webhook URLs remain supported for older integrations. + ## Typical examples - Example 1: Use git deployment to complete common operational tasks. diff --git a/docs/docs-summary.md b/docs/docs-summary.md index 105a6e3..9d6b2fb 100644 --- a/docs/docs-summary.md +++ b/docs/docs-summary.md @@ -41,7 +41,7 @@ DNSSEC can be enabled per domain and generates KSK/ZSK keys, DS records, and sig - Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack. - Installer: install.sh, builds assets as www-data and ensures permissions. - Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules. -- Deploy helper: scripts/deploy.sh syncs code to a server, runs composer/npm, migrations, and caches, and can push to Gitea/GitHub with automatic VERSION bump. +- Deploy helper: scripts/deploy.sh rsyncs to `root@192.168.100.50`, commits there, bumps VERSION, updates install.sh fallback, pushes to Git remotes from that server, then runs composer/npm, migrations, and caches. ## Packaging Debian packaging is supported via scripts: diff --git a/docs/installation.md b/docs/installation.md index c333b40..73054c2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -112,9 +112,10 @@ browser (Ctrl+Shift+R) after deployment. ## Deploy script -The repository ships with a deploy helper at `scripts/deploy.sh`. It syncs the -project to a remote server over SSH, then runs composer/npm, migrations, and -cache rebuilds as the web user. +The repository ships with a deploy helper at `scripts/deploy.sh`. It rsyncs the +project to `root@192.168.100.50:/var/www/jabali`, commits on that server, bumps +`VERSION`, updates the `install.sh` fallback, and pushes to Git remotes from +that server. Then it runs composer/npm, migrations, and cache rebuilds. Defaults (override via flags or env vars): - Host: `192.168.100.50` @@ -137,20 +138,32 @@ scripts/deploy.sh --dry-run scripts/deploy.sh --skip-npm --skip-cache ``` -Push to Git remotes (optional): +Push behavior controls: ``` -# Push to Gitea and/or GitHub before deploying -scripts/deploy.sh --push-gitea --push-github +# Deploy only (no push) +scripts/deploy.sh --skip-push -# Push to explicit URLs -scripts/deploy.sh --push-gitea --gitea-url http://192.168.100.100:3001/shukivaknin/jabali-panel.git \ - --push-github --github-url git@github.com:shukiv/jabali-panel.git +# Push only one remote +scripts/deploy.sh --no-push-github +scripts/deploy.sh --no-push-gitea + +# Push to explicit URLs from the test server +scripts/deploy.sh --gitea-url ssh://git@192.168.100.100:2222/shukivaknin/jabali-panel.git \ + --github-url git@github.com:shukiv/jabali-panel.git +``` + +GitHub push location: +``` +# Push to GitHub from the test server (required) +ssh root@192.168.100.50 +cd /var/www/jabali +git push origin main ``` Notes: -- `--push-gitea` / `--push-github` require a clean worktree. -- When pushing, the script bumps `VERSION`, updates the `install.sh` fallback, - and commits the version bump before pushing. +- Pushes run from `root@192.168.100.50` (not from local machine). +- Before each push, the script bumps `VERSION`, updates `install.sh` fallback, + and commits on the deploy server. - Rsync excludes `.env`, `storage/`, `vendor/`, `node_modules/`, `public/build/`, `bootstrap/cache/`, and SQLite DB files. Handle those separately if needed. - `--delete` passes `--delete` to rsync (dangerous). diff --git a/docs/onboarding.md b/docs/onboarding.md index a6b9a66..6db7e3b 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -51,6 +51,7 @@ From /var/www/jabali: - Do not push unless explicitly asked. - Bump VERSION before every push. - Keep install.sh version fallback in sync with VERSION. +- Push to GitHub from `root@192.168.100.50`. ## Where to Look for Examples - app/Filament/Admin/Pages and app/Filament/Jabali/Pages diff --git a/package-lock.json b/package-lock.json index 49da1c6..1904f86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "autoprefixer": "^10.4.16", - "axios": "^1.11.0", + "axios": "^1.13.5", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", "postcss": "^8.4.32", @@ -1181,13 +1181,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dev": true, "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, diff --git a/package.json b/package.json index 3dbb069..2a008b2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "autoprefixer": "^10.4.16", - "axios": "^1.11.0", + "axios": "^1.13.5", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", "postcss": "^8.4.32", diff --git a/routes/api.php b/routes/api.php index bfbe6b2..681ae90 100644 --- a/routes/api.php +++ b/routes/api.php @@ -25,13 +25,26 @@ Route::post('/phpmyadmin/verify-token', function (Request $request) { Cache::forget('phpmyadmin_token_'.$token); return response()->json($data); -}); +})->middleware('throttle:internal-api'); + +$allowInternalRequest = static function (Request $request): bool { + $remoteAddr = (string) $request->server('REMOTE_ADDR', $request->ip()); + $isLocalRequest = in_array($remoteAddr, ['127.0.0.1', '::1'], true); + + $configuredToken = trim((string) config('app.internal_api_token', '')); + $providedToken = trim((string) ( + $request->header('X-Jabali-Internal-Token') + ?? $request->input('internal_token') + ?? '' + )); + $hasValidToken = $configuredToken !== '' && $providedToken !== '' && hash_equals($configuredToken, $providedToken); + + return $isLocalRequest || $hasValidToken; +}; // Internal API for jabali-cache WordPress plugin -Route::post('/internal/page-cache', function (Request $request) { - // Only allow requests from localhost - $clientIp = $request->ip(); - if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) { +Route::post('/internal/page-cache', function (Request $request) use ($allowInternalRequest) { + if (! $allowInternalRequest($request)) { return response()->json(['error' => 'Forbidden'], 403); } @@ -68,7 +81,7 @@ Route::post('/internal/page-cache', function (Request $request) { if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) { $authKey = $matches[1]; $expectedSecret = substr(md5($authKey), 0, 32); - if ($secret !== $expectedSecret) { + if (! hash_equals($expectedSecret, $secret)) { return response()->json(['error' => 'Invalid secret'], 401); } } else { @@ -94,13 +107,11 @@ Route::post('/internal/page-cache', function (Request $request) { } catch (\Exception $e) { return response()->json(['error' => $e->getMessage()], 500); } -}); +})->middleware('throttle:internal-api'); // Internal API for smart page cache purging (called by jabali-cache WordPress plugin) -Route::post('/internal/page-cache-purge', function (Request $request) { - // Only allow requests from localhost - $clientIp = $request->ip(); - if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) { +Route::post('/internal/page-cache-purge', function (Request $request) use ($allowInternalRequest) { + if (! $allowInternalRequest($request)) { return response()->json(['error' => 'Forbidden'], 403); } @@ -137,7 +148,7 @@ Route::post('/internal/page-cache-purge', function (Request $request) { if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) { $authKey = $matches[1]; $expectedSecret = substr(md5($authKey), 0, 32); - if ($secret !== $expectedSecret) { + if (! hash_equals($expectedSecret, $secret)) { return response()->json(['error' => 'Invalid secret'], 401); } } else { @@ -164,9 +175,12 @@ Route::post('/internal/page-cache-purge', function (Request $request) { } catch (\Exception $e) { return response()->json(['error' => $e->getMessage()], 500); } -}); +})->middleware('throttle:internal-api'); -Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class); +Route::post('/webhooks/git/{deployment}', GitWebhookController::class) + ->middleware('throttle:git-webhooks'); +Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class) + ->middleware('throttle:git-webhooks'); Route::middleware(['auth:sanctum', 'abilities:automation']) ->prefix('automation') diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 4fa46a8..ee8d734 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -22,8 +22,9 @@ SKIP_CACHE=0 SKIP_AGENT_RESTART=0 DELETE_REMOTE=0 DRY_RUN=0 -PUSH_GITEA=0 -PUSH_GITHUB=0 +SKIP_PUSH=0 +PUSH_GITEA=1 +PUSH_GITHUB=1 SET_VERSION="" usage() { @@ -43,13 +44,16 @@ Options: --skip-agent-restart Skip restarting jabali-agent service --delete Pass --delete to rsync (dangerous) --dry-run Dry-run rsync only - --push-gitea Push current branch to Gitea before deploy + --skip-push Skip all git push operations + --push-gitea Push current branch to Gitea from deploy server (default: on) + --no-push-gitea Disable Gitea push --gitea-remote NAME Gitea git remote name (default: gitea) --gitea-url URL Push to this URL instead of a named remote - --push-github Push current branch to GitHub before deploy + --push-github Push current branch to GitHub from deploy server (default: on) + --no-push-github Disable GitHub push --github-remote NAME GitHub git remote name (default: origin) --github-url URL Push to this URL instead of a named remote - --version VALUE Set VERSION to a specific value before push + --version VALUE Set VERSION to a specific value before remote push -h, --help Show this help Environment overrides: @@ -107,10 +111,20 @@ while [[ $# -gt 0 ]]; do DRY_RUN=1 shift ;; + --skip-push) + SKIP_PUSH=1 + PUSH_GITEA=0 + PUSH_GITHUB=0 + shift + ;; --push-gitea) PUSH_GITEA=1 shift ;; + --no-push-gitea) + PUSH_GITEA=0 + shift + ;; --gitea-remote) GITEA_REMOTE="$2" shift 2 @@ -123,6 +137,10 @@ while [[ $# -gt 0 ]]; do PUSH_GITHUB=1 shift ;; + --no-push-github) + PUSH_GITHUB=0 + shift + ;; --github-remote) GITHUB_REMOTE="$2" shift 2 @@ -149,81 +167,121 @@ done REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}" -ensure_clean_worktree() { - if ! git -C "$ROOT_DIR" diff --quiet || ! git -C "$ROOT_DIR" diff --cached --quiet; then - echo "Working tree is dirty. Commit or stash changes before pushing." +ensure_remote_git_clean() { + local status_output + status_output="$(remote_run "if [[ ! -d \"$DEPLOY_PATH/.git\" ]]; then echo '__NO_GIT__'; exit 0; fi; cd \"$DEPLOY_PATH\" && git status --porcelain")" + + if [[ "$status_output" == "__NO_GIT__" ]]; then + echo "Remote path is not a git repository: $DEPLOY_PATH" + exit 1 + fi + + if [[ -n "$status_output" ]]; then + echo "Remote git worktree is dirty at $DEPLOY_PATH. Commit or stash remote changes first." + echo "$status_output" exit 1 fi } -get_current_version() { - sed -n 's/^VERSION=//p' "$ROOT_DIR/VERSION" -} +remote_commit_and_push() { + local local_head push_branch + local_head="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)" -bump_version() { - local current new base num - current="$(get_current_version)" - - if [[ -n "$SET_VERSION" ]]; then - new="$SET_VERSION" + if [[ -n "$PUSH_BRANCH" ]]; then + push_branch="$PUSH_BRANCH" else - if [[ "$current" =~ ^(.+-rc)([0-9]+)?$ ]]; then - base="${BASH_REMATCH[1]}" - num="${BASH_REMATCH[2]}" - if [[ -z "$num" ]]; then - num=1 - else - num=$((num + 1)) - fi - new="${base}${num}" - elif [[ "$current" =~ ^(.+?)([0-9]+)$ ]]; then - new="${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))" + push_branch="$(remote_run "cd \"$DEPLOY_PATH\" && git rev-parse --abbrev-ref HEAD")" + if [[ -z "$push_branch" || "$push_branch" == "HEAD" ]]; then + push_branch="main" + fi + fi + + ssh -o StrictHostKeyChecking=no "$REMOTE" \ + DEPLOY_PATH="$DEPLOY_PATH" \ + PUSH_BRANCH="$push_branch" \ + PUSH_GITEA="$PUSH_GITEA" \ + PUSH_GITHUB="$PUSH_GITHUB" \ + GITEA_REMOTE="$GITEA_REMOTE" \ + GITEA_URL="$GITEA_URL" \ + GITHUB_REMOTE="$GITHUB_REMOTE" \ + GITHUB_URL="$GITHUB_URL" \ + SET_VERSION="$SET_VERSION" \ + LOCAL_HEAD="$local_head" \ + bash -s <<'EOF' +set -euo pipefail + +cd "$DEPLOY_PATH" + +if [[ ! -d .git ]]; then + echo "Remote path is not a git repository: $DEPLOY_PATH" >&2 + exit 1 +fi + +git config --global --add safe.directory "$DEPLOY_PATH" >/dev/null 2>&1 || true +if ! git config user.name >/dev/null; then + git config user.name "Jabali Deploy" +fi +if ! git config user.email >/dev/null; then + git config user.email "root@$(hostname -f 2>/dev/null || hostname)" +fi + +current="$(sed -n 's/^VERSION=//p' VERSION || true)" +if [[ -z "$current" ]]; then + echo "VERSION file missing or invalid on remote." >&2 + exit 1 +fi + +if [[ -n "${SET_VERSION:-}" ]]; then + new="$SET_VERSION" +else + if [[ "$current" =~ ^(.+-rc)([0-9]+)?$ ]]; then + base="${BASH_REMATCH[1]}" + num="${BASH_REMATCH[2]}" + if [[ -z "$num" ]]; then + num=1 else - echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly." - exit 1 + num=$((num + 1)) fi - fi - - if [[ "$new" == "$current" ]]; then - echo "VERSION is already '$current'. Use --version to set a new value." + new="${base}${num}" + elif [[ "$current" =~ ^(.+?)([0-9]+)$ ]]; then + new="${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))" + else + echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly." >&2 exit 1 fi +fi - printf 'VERSION=%s\n' "$new" > "$ROOT_DIR/VERSION" - perl -0pi -e "s/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-[^\\\"]+\\}\\\"/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-$new\\}\\\"/g" "$ROOT_DIR/install.sh" +if [[ "$new" == "$current" ]]; then + echo "VERSION is already '$current'. Use --version to set a new value." >&2 + exit 1 +fi - git -C "$ROOT_DIR" add VERSION install.sh - if ! git -C "$ROOT_DIR" diff --cached --quiet; then - git -C "$ROOT_DIR" commit -m "Bump VERSION to $new" - fi -} +printf 'VERSION=%s\n' "$new" > VERSION +sed -i -E "s|JABALI_VERSION=\"\\$\\{JABALI_VERSION:-[^}]+\\}\"|JABALI_VERSION=\"\\\${JABALI_VERSION:-$new}\"|" install.sh -prepare_push() { - ensure_clean_worktree - bump_version -} +git add -A +if git diff --cached --quiet; then + echo "No changes detected after sync/version bump; skipping commit." +else + git commit -m "Deploy sync from ${LOCAL_HEAD} (v${new})" +fi -push_remote() { - local label="$1" - local remote_name="$2" - local remote_url="$3" - local target - - if [[ -n "$remote_url" ]]; then - target="$remote_url" +if [[ "$PUSH_GITEA" -eq 1 ]]; then + if [[ -n "$GITEA_URL" ]]; then + git push "$GITEA_URL" "$PUSH_BRANCH" else - if ! git -C "$ROOT_DIR" remote get-url "$remote_name" >/dev/null 2>&1; then - echo "$label remote '$remote_name' not found. Use --${label,,}-url or --${label,,}-remote." - exit 1 - fi - target="$remote_name" + git push "$GITEA_REMOTE" "$PUSH_BRANCH" fi +fi - if [[ -z "$PUSH_BRANCH" ]]; then - PUSH_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)" +if [[ "$PUSH_GITHUB" -eq 1 ]]; then + if [[ -n "$GITHUB_URL" ]]; then + git push "$GITHUB_URL" "$PUSH_BRANCH" + else + git push "$GITHUB_REMOTE" "$PUSH_BRANCH" fi - - git -C "$ROOT_DIR" push "$target" "$PUSH_BRANCH" +fi +EOF } rsync_project() { @@ -275,18 +333,9 @@ ensure_remote_permissions() { echo "Deploying to ${REMOTE}:${DEPLOY_PATH}" -if [[ "$PUSH_GITEA" -eq 1 ]]; then - prepare_push - echo "Pushing to Gitea..." - push_remote "Gitea" "$GITEA_REMOTE" "$GITEA_URL" -fi - -if [[ "$PUSH_GITHUB" -eq 1 ]]; then - if [[ "$PUSH_GITEA" -ne 1 ]]; then - prepare_push - fi - echo "Pushing to GitHub..." - push_remote "GitHub" "$GITHUB_REMOTE" "$GITHUB_URL" +if [[ "$DRY_RUN" -eq 0 && "$SKIP_PUSH" -eq 0 && ( "$PUSH_GITEA" -eq 1 || "$PUSH_GITHUB" -eq 1 ) ]]; then + echo "Validating remote git worktree..." + ensure_remote_git_clean fi if [[ "$SKIP_SYNC" -eq 0 ]]; then @@ -299,6 +348,11 @@ if [[ "$DRY_RUN" -eq 1 ]]; then exit 0 fi +if [[ "$SKIP_PUSH" -eq 0 && ( "$PUSH_GITEA" -eq 1 || "$PUSH_GITHUB" -eq 1 ) ]]; then + echo "Committing and pushing from ${REMOTE}..." + remote_commit_and_push +fi + echo "Ensuring remote permissions..." ensure_remote_permissions diff --git a/tests/Feature/ApiSecurityHardeningTest.php b/tests/Feature/ApiSecurityHardeningTest.php new file mode 100644 index 0000000..13c04e1 --- /dev/null +++ b/tests/Feature/ApiSecurityHardeningTest.php @@ -0,0 +1,98 @@ +createDeployment(); + + $response = $this->postJson("/api/webhooks/git/{$deployment->id}", ['ref' => 'refs/heads/main']); + + $response->assertStatus(403); + Bus::assertNotDispatched(RunGitDeployment::class); + } + + public function test_git_webhook_accepts_hmac_signature(): void + { + Bus::fake(); + $deployment = $this->createDeployment(); + $payload = ['ref' => 'refs/heads/main']; + $signature = hash_hmac('sha256', (string) json_encode($payload), $deployment->secret_token); + + $response = $this + ->withHeader('X-Jabali-Signature', $signature) + ->postJson("/api/webhooks/git/{$deployment->id}", $payload); + + $response->assertStatus(200); + Bus::assertDispatched(RunGitDeployment::class); + } + + public function test_git_webhook_accepts_legacy_token_route(): void + { + Bus::fake(); + $deployment = $this->createDeployment(); + + $response = $this->postJson( + "/api/webhooks/git/{$deployment->id}/{$deployment->secret_token}", + ['ref' => 'refs/heads/main'] + ); + + $response->assertStatus(200); + Bus::assertDispatched(RunGitDeployment::class); + } + + public function test_internal_api_rejects_non_local_without_internal_token(): void + { + config()->set('app.internal_api_token', null); + + $response = $this + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.10']) + ->postJson('/api/internal/page-cache', []); + + $response->assertStatus(403); + } + + public function test_internal_api_allows_non_local_with_internal_token(): void + { + config()->set('app.internal_api_token', 'test-internal-token'); + + $response = $this + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.10']) + ->withHeader('X-Jabali-Internal-Token', 'test-internal-token') + ->postJson('/api/internal/page-cache', []); + + $response->assertStatus(400); + $response->assertJson(['error' => 'Domain is required']); + } + + private function createDeployment(): GitDeployment + { + $user = User::factory()->create(); + $domain = Domain::factory()->for($user)->create(); + + return GitDeployment::create([ + 'user_id' => $user->id, + 'domain_id' => $domain->id, + 'repo_url' => 'https://example.com/repo.git', + 'branch' => 'main', + 'deploy_path' => '/home/'.$user->username.'/domains/'.$domain->domain.'/public_html', + 'auto_deploy' => true, + 'secret_token' => 'test-secret-token-1234567890', + ]); + } +}