Compare commits
5 Commits
5d502699ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e230ac17aa | |||
|
|
7125c535cc | ||
|
|
2dfc139f42 | ||
|
|
52e116e671 | ||
|
|
0c6402604d |
10
.env.example
10
.env.example
@@ -29,6 +29,7 @@ SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
# SESSION_SECURE_COOKIE=true
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
@@ -59,4 +60,13 @@ AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
# Comma-separated list of trusted proxies. Use "*" only when intentional.
|
||||
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# Optional internal API shared token for non-localhost calls.
|
||||
# JABALI_INTERNAL_API_TOKEN=
|
||||
|
||||
# Set to true only when remote panel migration discovery must skip TLS verification.
|
||||
# JABALI_IMPORT_INSECURE_TLS=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ CLAUDE.md
|
||||
/jabali-panel_*.deb
|
||||
/jabali-deps_*.deb
|
||||
.git-credentials
|
||||
|
||||
# Local repository configuration (do not commit)
|
||||
config.toml
|
||||
|
||||
1
AGENT.md
1
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -213,7 +213,7 @@ class ImportProcessCommand extends Command
|
||||
Domain::create([
|
||||
'domain' => $account->main_domain,
|
||||
'user_id' => $user->id,
|
||||
'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public",
|
||||
'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public_html",
|
||||
'is_active' => true,
|
||||
]);
|
||||
$account->addLog("Created main domain: {$account->main_domain}");
|
||||
@@ -235,7 +235,7 @@ class ImportProcessCommand extends Command
|
||||
Domain::create([
|
||||
'domain' => $domain,
|
||||
'user_id' => $user->id,
|
||||
'document_root' => "/home/{$user->username}/domains/{$domain}/public",
|
||||
'document_root' => "/home/{$user->username}/domains/{$domain}/public_html",
|
||||
'is_active' => true,
|
||||
]);
|
||||
$account->addLog("Created addon domain: {$domain}");
|
||||
@@ -290,7 +290,7 @@ class ImportProcessCommand extends Command
|
||||
// Copy public_html to the domain
|
||||
$publicHtml = "$homeDir/public_html";
|
||||
if (is_dir($publicHtml) && $account->main_domain) {
|
||||
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public";
|
||||
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public_html";
|
||||
if (is_dir($destDir)) {
|
||||
exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
|
||||
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
|
||||
@@ -316,7 +316,7 @@ class ImportProcessCommand extends Command
|
||||
$publicHtml = "$domainDir/public_html";
|
||||
|
||||
if (is_dir($publicHtml)) {
|
||||
$destDir = "/home/{$user->username}/domains/{$domain}/public";
|
||||
$destDir = "/home/{$user->username}/domains/{$domain}/public_html";
|
||||
if (is_dir($destDir)) {
|
||||
exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
|
||||
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -164,30 +164,27 @@ class User extends Authenticatable implements FilamentUser
|
||||
*/
|
||||
public function getDiskUsageBytes(): int
|
||||
{
|
||||
// Try to get usage from quota system first (more accurate)
|
||||
// Disk usage must be obtained via the agent (root) to avoid permission-based undercounting.
|
||||
try {
|
||||
$agent = new \App\Services\Agent\AgentClient;
|
||||
$result = $agent->quotaGet($this->username, '/');
|
||||
$agent = new \App\Services\Agent\AgentClient(
|
||||
(string) config('jabali.agent.socket', '/var/run/jabali/agent.sock'),
|
||||
(int) config('jabali.agent.timeout', 120),
|
||||
);
|
||||
|
||||
$mount = $this->home_directory ?: ("/home/{$this->username}");
|
||||
$result = $agent->quotaGet($this->username, $mount);
|
||||
|
||||
if (($result['success'] ?? false) && isset($result['used_mb'])) {
|
||||
return (int) ($result['used_mb'] * 1024 * 1024);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Fall back to du command
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('Disk usage read failed via agent: '.$e->getMessage(), [
|
||||
'username' => $this->username,
|
||||
]);
|
||||
}
|
||||
|
||||
// Fallback: try du command (may not work if www-data can't read home dir)
|
||||
$homeDir = $this->home_directory;
|
||||
|
||||
if (! is_dir($homeDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$output = shell_exec('du -sb '.escapeshellarg($homeDir).' 2>/dev/null | cut -f1');
|
||||
|
||||
return (int) trim($output ?: '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted disk usage string.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
118
bin/jabali-agent
118
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");
|
||||
|
||||
@@ -20658,6 +20686,34 @@ function quotaGet(array $params): array
|
||||
return ['success' => false, 'error' => 'Username is required'];
|
||||
}
|
||||
|
||||
// Root-level disk usage probe for /home/<user>.
|
||||
$getDuUsageMb = static function (string $user): float {
|
||||
$homeDir = "/home/{$user}";
|
||||
if (!is_dir($homeDir)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$duOutput = trim(shell_exec("du -sk " . escapeshellarg($homeDir) . " 2>/dev/null | cut -f1") ?? '');
|
||||
if ($duOutput === '' || preg_match('/^\d+$/', $duOutput) !== 1) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(((int) $duOutput) / 1024, 2);
|
||||
};
|
||||
|
||||
// Quota counters can be stale on some filesystems; prefer the larger value.
|
||||
$normalizeUsage = static function (float $usedMb, float $softMb, float $hardMb, float $duMb): array {
|
||||
$finalUsedMb = $duMb > $usedMb ? $duMb : $usedMb;
|
||||
|
||||
return [
|
||||
'used_mb' => $finalUsedMb,
|
||||
'soft_mb' => $softMb,
|
||||
'hard_mb' => $hardMb,
|
||||
'usage_percent' => $hardMb > 0 ? round(($finalUsedMb / $hardMb) * 100, 1) : 0,
|
||||
'quota_source' => $duMb > $usedMb ? 'du_override' : 'quota',
|
||||
];
|
||||
};
|
||||
|
||||
// Find the actual mount point
|
||||
$findMount = trim(shell_exec("df --output=target " . escapeshellarg($mountPoint) . " 2>/dev/null | tail -1") ?? '');
|
||||
if (empty($findMount)) {
|
||||
@@ -20680,16 +20736,8 @@ function quotaGet(array $params): array
|
||||
$repOutput = trim(shell_exec($cmd2) ?? '');
|
||||
|
||||
if (empty($repOutput)) {
|
||||
// Quota not enabled - use du to calculate actual disk usage
|
||||
$homeDir = "/home/{$username}";
|
||||
$usedMb = 0;
|
||||
|
||||
if (is_dir($homeDir)) {
|
||||
// Use du to get actual disk usage in KB
|
||||
$duOutput = trim(shell_exec("du -sk " . escapeshellarg($homeDir) . " 2>/dev/null | cut -f1") ?? '0');
|
||||
$usedKb = (int)$duOutput;
|
||||
$usedMb = round($usedKb / 1024, 2);
|
||||
}
|
||||
// Quota not enabled - use du to calculate actual disk usage.
|
||||
$usedMb = $getDuUsageMb($username);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
@@ -20699,7 +20747,7 @@ function quotaGet(array $params): array
|
||||
'soft_mb' => 0,
|
||||
'hard_mb' => 0,
|
||||
'usage_percent' => 0,
|
||||
'quota_source' => 'du' // Indicate that we used du fallback
|
||||
'quota_source' => 'du',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -20707,18 +20755,21 @@ function quotaGet(array $params): array
|
||||
// Format: username -- used soft hard grace used soft hard grace
|
||||
$parts = preg_split('/\s+/', $repOutput);
|
||||
if (count($parts) >= 5) {
|
||||
$usedKb = (int)$parts[2];
|
||||
$softKb = (int)$parts[3];
|
||||
$hardKb = (int)$parts[4];
|
||||
$usedMb = round(((int) $parts[2]) / 1024, 2);
|
||||
$softMb = round(((int) $parts[3]) / 1024, 2);
|
||||
$hardMb = round(((int) $parts[4]) / 1024, 2);
|
||||
$duMb = $getDuUsageMb($username);
|
||||
$usage = $normalizeUsage($usedMb, $softMb, $hardMb, $duMb);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'username' => $username,
|
||||
'has_quota' => $softKb > 0 || $hardKb > 0,
|
||||
'used_mb' => round($usedKb / 1024, 2),
|
||||
'soft_mb' => round($softKb / 1024, 2),
|
||||
'hard_mb' => round($hardKb / 1024, 2),
|
||||
'usage_percent' => $hardKb > 0 ? round(($usedKb / $hardKb) * 100, 1) : 0
|
||||
'has_quota' => $softMb > 0 || $hardMb > 0,
|
||||
'used_mb' => $usage['used_mb'],
|
||||
'soft_mb' => $usage['soft_mb'],
|
||||
'hard_mb' => $usage['hard_mb'],
|
||||
'usage_percent' => $usage['usage_percent'],
|
||||
'quota_source' => $usage['quota_source'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20738,14 +20789,21 @@ function quotaGet(array $params): array
|
||||
}
|
||||
}
|
||||
|
||||
$usedMb = round($usedKb / 1024, 2);
|
||||
$softMb = round($softKb / 1024, 2);
|
||||
$hardMb = round($hardKb / 1024, 2);
|
||||
$duMb = $getDuUsageMb($username);
|
||||
$usage = $normalizeUsage($usedMb, $softMb, $hardMb, $duMb);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'username' => $username,
|
||||
'has_quota' => $softKb > 0 || $hardKb > 0,
|
||||
'used_mb' => round($usedKb / 1024, 2),
|
||||
'soft_mb' => round($softKb / 1024, 2),
|
||||
'hard_mb' => round($hardKb / 1024, 2),
|
||||
'usage_percent' => $hardKb > 0 ? round(($usedKb / $hardKb) * 100, 1) : 0
|
||||
'has_quota' => $softMb > 0 || $hardMb > 0,
|
||||
'used_mb' => $usage['used_mb'],
|
||||
'soft_mb' => $usage['soft_mb'],
|
||||
'hard_mb' => $usage['hard_mb'],
|
||||
'usage_percent' => $usage['usage_percent'],
|
||||
'quota_source' => $usage['quota_source'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
119
composer.lock
generated
119
composer.lock
generated
@@ -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"
|
||||
}
|
||||
|
||||
26
config.toml.example
Normal file
26
config.toml.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Jabali Panel repository config
|
||||
#
|
||||
# Used by `scripts/deploy.sh` (CLI flags still override these settings).
|
||||
# Keep secrets out of this file. Prefer SSH keys and server-side git remotes.
|
||||
|
||||
[deploy]
|
||||
# Test server (where GitHub deploy key is configured)
|
||||
host = "192.168.100.50"
|
||||
user = "root"
|
||||
path = "/var/www/jabali"
|
||||
www_user = "www-data"
|
||||
|
||||
# Optional: keep npm cache outside the repo (saves time on repeated builds)
|
||||
# npm_cache_dir = "/var/www/.npm"
|
||||
|
||||
# Optional: override the branch that gets pushed from the deploy server
|
||||
push_branch = "main"
|
||||
|
||||
# Optional: push to explicit URLs (instead of relying on named remotes)
|
||||
# These pushes run FROM the test server.
|
||||
gitea_url = "ssh://git@192.168.100.100:2222/shukivaknin/jabali-panel.git"
|
||||
github_url = "git@github.com:shukiv/jabali-panel.git"
|
||||
|
||||
# If you prefer named remotes on the deploy server instead of URLs:
|
||||
# gitea_remote = "gitea"
|
||||
# github_remote = "origin"
|
||||
@@ -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'),
|
||||
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
150
docs/architecture/mcp-and-filament-blueprint.md
Normal file
150
docs/architecture/mcp-and-filament-blueprint.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# MCP and Filament Blueprint (Jabali Panel)
|
||||
|
||||
Last updated: 2026-02-12
|
||||
|
||||
This document is an internal developer blueprint for working on Jabali Panel with:
|
||||
|
||||
- MCP tooling (Model Context Protocol) for fast, version-correct introspection and docs.
|
||||
- Filament (Admin + User panels) conventions and project-specific UI rules.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep changes consistent with the existing architecture and UI.
|
||||
- Prefer version-specific documentation and project-aware inspection.
|
||||
- Avoid UI regressions by following Filament-native patterns.
|
||||
- Keep privileged operations isolated behind the agent.
|
||||
|
||||
## MCP Tooling Blueprint
|
||||
|
||||
Jabali is set up to be worked on with MCP tools. Use them to reduce guesswork and prevent version drift.
|
||||
|
||||
### 1) Laravel Boost (Most Important)
|
||||
|
||||
Laravel Boost MCP gives application-aware tools (routes, config, DB schema, logs, and version-specific docs).
|
||||
|
||||
Use it when:
|
||||
- You need to confirm route names/paths and middleware.
|
||||
- You need to confirm the active config (not just what you expect in `.env`).
|
||||
- You need the DB schema or sample records to understand existing behavior.
|
||||
- You need version-specific docs for Laravel/Livewire/Filament/Tailwind.
|
||||
|
||||
Preferred workflow:
|
||||
- `application-info` to confirm versions and installed packages.
|
||||
- `list-routes` to find the correct URL, route names, and panel prefixes.
|
||||
- `get-config` for runtime config values.
|
||||
- `database-schema` and `database-query` (read-only) to verify tables and relationships.
|
||||
- `read-log-entries` / `last-error` to confirm the active failure.
|
||||
- `search-docs` before implementing anything that depends on framework behavior.
|
||||
|
||||
Project rule of thumb:
|
||||
- Before making a structural change in the panel, list relevant routes and key config values first.
|
||||
|
||||
### 2) Jabali Docs MCP Server
|
||||
|
||||
The repository includes `mcp-docs-server/` which exposes project docs as MCP resources/tools.
|
||||
|
||||
What it is useful for:
|
||||
- Quick search across `README.md`, `AGENT.md`, and changelog content.
|
||||
- Pulling a specific section by title.
|
||||
|
||||
This is not a runtime dependency of the panel. It is a developer tooling layer.
|
||||
|
||||
### 3) Frontend and Quality MCPs
|
||||
|
||||
Use these to audit and reduce UI/HTML/CSS regressions:
|
||||
|
||||
- `css-mcp`:
|
||||
- Analyze CSS quality/complexity.
|
||||
- Check browser compatibility for specific CSS features.
|
||||
- Pull MDN docs for CSS properties/selectors when implementing UI.
|
||||
- `stylelint`:
|
||||
- Lint CSS where applicable (note: Filament pages should not use custom CSS files).
|
||||
- `webdev-tools`:
|
||||
- Prettier formatting for snippets.
|
||||
- `php -l` lint for PHP syntax.
|
||||
- HTML validation for standalone HTML.
|
||||
|
||||
Security rule:
|
||||
- Do not send secrets (tokens, passwords, private keys) into any tool query.
|
||||
|
||||
## Filament Blueprint (How Jabali Panels Are Built)
|
||||
|
||||
Jabali has two Filament panels:
|
||||
|
||||
- Admin panel: server-wide operations.
|
||||
- User panel ("Jabali" panel): tenant/user operations.
|
||||
|
||||
High-level structure:
|
||||
- `app/Filament/Admin/*` for admin.
|
||||
- `app/Filament/Jabali/*` for user.
|
||||
|
||||
### Pages vs Resources
|
||||
|
||||
Default decision:
|
||||
- Use a Filament Resource when the UI is primarily CRUD around an Eloquent model.
|
||||
- Use a Filament Page when the UI is a dashboard, a multi-step wizard, or merges multiple concerns into a single screen.
|
||||
|
||||
### Project UI Rules (Strict)
|
||||
|
||||
These rules exist to keep the UI consistent and maintainable:
|
||||
|
||||
- Use Filament native components for layout and UI.
|
||||
- Avoid raw HTML layout in Filament pages.
|
||||
- Avoid custom CSS for Filament pages.
|
||||
- Use Filament tables for list data.
|
||||
|
||||
Practical mapping:
|
||||
- Layout: `Filament\Schemas\Components\Section`, `Grid`, `Tabs`, `Group`.
|
||||
- Actions: `Filament\Actions\Action`.
|
||||
- List data: `HasTable` / `InteractsWithTable` or `EmbeddedTable`.
|
||||
|
||||
### Tabs + Tables Gotcha
|
||||
|
||||
There is a known class of issues when a table is nested incorrectly inside schema Tabs.
|
||||
|
||||
Rule of thumb:
|
||||
- Prefer `EmbeddedTable::make()` in schema layouts.
|
||||
- Avoid mounting tables inside `View::make()` within `Tabs::make()` unless you know the action mounting behavior is preserved.
|
||||
|
||||
### Translations and RTL
|
||||
|
||||
Jabali uses JSON-based translations.
|
||||
|
||||
Rules:
|
||||
- Use the English string as the translation key: `__('Create Domain')`.
|
||||
- Do not introduce dotted translation keys like `__('domain.create')`.
|
||||
- Ensure UI reads correctly in RTL locales (Arabic/Hebrew).
|
||||
|
||||
### Privileged Operations (Agent Boundary)
|
||||
|
||||
The Laravel app is the control plane. Privileged system operations are executed by the root-level agent.
|
||||
|
||||
Key points:
|
||||
- The agent is `bin/jabali-agent`.
|
||||
- The panel should call privileged operations through the Agent client service (not by shelling out directly).
|
||||
- Keep all path and input validation strict before an agent call.
|
||||
|
||||
## Filament Blueprint Planning (Feature Specs)
|
||||
|
||||
When writing an implementation plan for a Filament feature, use Filament Blueprint planning docs as a checklist.
|
||||
|
||||
Reference:
|
||||
- `vendor/filament/blueprint/resources/markdown/planning/overview.md`
|
||||
|
||||
At minimum, a plan should specify:
|
||||
- Data model changes (tables, columns, indexes, relationships).
|
||||
- Panel placement (Admin vs User) and navigation.
|
||||
- Page/Resource decisions.
|
||||
- Authorization model (policies/guards).
|
||||
- Background jobs (for long-running operations).
|
||||
- Audit logging events.
|
||||
- Tests (Feature tests for endpoints and Livewire/Filament behaviors).
|
||||
|
||||
## Development Checklist (Per Feature)
|
||||
|
||||
- Confirm the correct panel and route prefix.
|
||||
- List routes and verify config assumptions (Boost tools).
|
||||
- Follow Filament-native components (no custom HTML/CSS in Filament pages).
|
||||
- Use tables for list data.
|
||||
- Keep agent boundary intact for privileged operations.
|
||||
- Add or update tests and run targeted test commands.
|
||||
@@ -14,6 +14,7 @@ Last updated: 2026-02-10
|
||||
- /var/www/jabali/docs/installation.md - Debian package install path, Filament notifications patch, and deploy script usage.
|
||||
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
|
||||
- /var/www/jabali/docs/architecture/directadmin-migration-blueprint.md - Blueprint for migrating DirectAdmin accounts into Jabali.
|
||||
- /var/www/jabali/docs/architecture/mcp-and-filament-blueprint.md - Developer blueprint for MCP tooling and Filament panel conventions.
|
||||
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
|
||||
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.
|
||||
- /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -112,11 +112,19 @@ 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, env vars, or config.toml):
|
||||
|
||||
Config file:
|
||||
- `config.toml` (ignored by git) is read automatically if present.
|
||||
- Start from `config.toml.example`.
|
||||
- Set `CONFIG_FILE` to use an alternate TOML file path.
|
||||
- Supported keys are in `[deploy]` (for example: `host`, `user`, `path`, `www_user`, `push_branch`, `gitea_url`, `github_url`).
|
||||
|
||||
Defaults (override via flags or env vars):
|
||||
- Host: `192.168.100.50`
|
||||
- User: `root`
|
||||
- Path: `/var/www/jabali`
|
||||
@@ -137,20 +145,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
21
install.sh
21
install.sh
@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
||||
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
|
||||
fi
|
||||
JABALI_VERSION="${JABALI_VERSION:-0.9-rc62}"
|
||||
JABALI_VERSION="${JABALI_VERSION:-0.9-rc66}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
@@ -414,6 +414,7 @@ install_packages() {
|
||||
wget
|
||||
zip
|
||||
unzip
|
||||
cron
|
||||
htop
|
||||
net-tools
|
||||
dnsutils
|
||||
@@ -3020,6 +3021,18 @@ setup_scheduler_cron() {
|
||||
mkdir -p "$JABALI_DIR/storage/logs"
|
||||
chown -R www-data:www-data "$JABALI_DIR/storage/logs"
|
||||
|
||||
# Ensure crontab command is available
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
warn "crontab command not found, installing cron package..."
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq cron || true
|
||||
fi
|
||||
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
warn "Unable to configure scheduler: crontab command is still missing"
|
||||
return
|
||||
fi
|
||||
|
||||
# Ensure cron service is enabled and running
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl enable cron >/dev/null 2>&1 || true
|
||||
@@ -3030,8 +3043,8 @@ setup_scheduler_cron() {
|
||||
CRON_LINE="* * * * * cd $JABALI_DIR && php artisan schedule:run >> /dev/null 2>&1"
|
||||
|
||||
# Add to www-data's crontab (not root) to avoid permission issues with log files
|
||||
if ! sudo -u www-data crontab -l 2>/dev/null | grep -q "artisan schedule:run"; then
|
||||
(sudo -u www-data crontab -l 2>/dev/null; echo "$CRON_LINE") | sudo -u www-data crontab -
|
||||
if ! crontab -u www-data -l 2>/dev/null | grep -q "artisan schedule:run"; then
|
||||
(crontab -u www-data -l 2>/dev/null; echo "$CRON_LINE") | crontab -u www-data -
|
||||
log "Laravel scheduler cron job added"
|
||||
else
|
||||
log "Laravel scheduler cron job already exists"
|
||||
@@ -3617,7 +3630,9 @@ uninstall() {
|
||||
rm -f /etc/logrotate.d/jabali-users
|
||||
|
||||
# Remove www-data cron jobs (Laravel scheduler)
|
||||
if command -v crontab >/dev/null 2>&1; then
|
||||
crontab -u www-data -r 2>/dev/null || true
|
||||
fi
|
||||
|
||||
log "Configuration files cleaned"
|
||||
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -3,16 +3,120 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
DEPLOY_HOST="${DEPLOY_HOST:-192.168.100.50}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-root}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/var/www/jabali}"
|
||||
WWW_USER="${WWW_USER:-www-data}"
|
||||
NPM_CACHE_DIR="${NPM_CACHE_DIR:-}"
|
||||
GITEA_REMOTE="${GITEA_REMOTE:-gitea}"
|
||||
GITEA_URL="${GITEA_URL:-}"
|
||||
GITHUB_REMOTE="${GITHUB_REMOTE:-origin}"
|
||||
GITHUB_URL="${GITHUB_URL:-}"
|
||||
PUSH_BRANCH="${PUSH_BRANCH:-}"
|
||||
|
||||
CONFIG_FILE="${CONFIG_FILE:-$ROOT_DIR/config.toml}"
|
||||
|
||||
# Capture env overrides before we assign defaults so config.toml can sit between
|
||||
# defaults and environment: CLI > env > config > defaults.
|
||||
ENV_DEPLOY_HOST="${DEPLOY_HOST-}"
|
||||
ENV_DEPLOY_USER="${DEPLOY_USER-}"
|
||||
ENV_DEPLOY_PATH="${DEPLOY_PATH-}"
|
||||
ENV_WWW_USER="${WWW_USER-}"
|
||||
ENV_NPM_CACHE_DIR="${NPM_CACHE_DIR-}"
|
||||
ENV_GITEA_REMOTE="${GITEA_REMOTE-}"
|
||||
ENV_GITEA_URL="${GITEA_URL-}"
|
||||
ENV_GITHUB_REMOTE="${GITHUB_REMOTE-}"
|
||||
ENV_GITHUB_URL="${GITHUB_URL-}"
|
||||
ENV_PUSH_BRANCH="${PUSH_BRANCH-}"
|
||||
|
||||
DEPLOY_HOST="192.168.100.50"
|
||||
DEPLOY_USER="root"
|
||||
DEPLOY_PATH="/var/www/jabali"
|
||||
WWW_USER="www-data"
|
||||
NPM_CACHE_DIR=""
|
||||
GITEA_REMOTE="gitea"
|
||||
GITEA_URL=""
|
||||
GITHUB_REMOTE="origin"
|
||||
GITHUB_URL=""
|
||||
PUSH_BRANCH=""
|
||||
|
||||
trim_ws() {
|
||||
local s="${1:-}"
|
||||
s="$(echo "$s" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
toml_unquote() {
|
||||
local v
|
||||
v="$(trim_ws "${1:-}")"
|
||||
|
||||
# Only accept simple double-quoted strings, booleans, or integers.
|
||||
if [[ ${#v} -ge 2 && "${v:0:1}" == '"' && "${v: -1}" == '"' ]]; then
|
||||
printf '%s' "${v:1:${#v}-2}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$v" =~ ^(true|false)$ ]]; then
|
||||
printf '%s' "$v"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$v" =~ ^-?[0-9]+$ ]]; then
|
||||
printf '%s' "$v"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
load_config_toml() {
|
||||
local file section line key raw value
|
||||
file="$1"
|
||||
section=""
|
||||
|
||||
[[ -f "$file" ]] || return 0
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Strip comments and whitespace.
|
||||
line="${line%%#*}"
|
||||
line="$(trim_ws "$line")"
|
||||
[[ -z "$line" ]] && continue
|
||||
|
||||
if [[ "$line" =~ ^\[([A-Za-z0-9_.-]+)\]$ ]]; then
|
||||
section="${BASH_REMATCH[1]}"
|
||||
continue
|
||||
fi
|
||||
|
||||
[[ "$section" == "deploy" ]] || continue
|
||||
|
||||
if [[ "$line" =~ ^([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.+)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
raw="${BASH_REMATCH[2]}"
|
||||
value=""
|
||||
|
||||
if ! value="$(toml_unquote "$raw")"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$key" in
|
||||
host) DEPLOY_HOST="$value" ;;
|
||||
user) DEPLOY_USER="$value" ;;
|
||||
path) DEPLOY_PATH="$value" ;;
|
||||
www_user) WWW_USER="$value" ;;
|
||||
npm_cache_dir) NPM_CACHE_DIR="$value" ;;
|
||||
gitea_remote) GITEA_REMOTE="$value" ;;
|
||||
gitea_url) GITEA_URL="$value" ;;
|
||||
github_remote) GITHUB_REMOTE="$value" ;;
|
||||
github_url) GITHUB_URL="$value" ;;
|
||||
push_branch) PUSH_BRANCH="$value" ;;
|
||||
esac
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
load_config_toml "$CONFIG_FILE"
|
||||
|
||||
# Apply environment overrides on top of config.
|
||||
if [[ -n "${ENV_DEPLOY_HOST:-}" ]]; then DEPLOY_HOST="$ENV_DEPLOY_HOST"; fi
|
||||
if [[ -n "${ENV_DEPLOY_USER:-}" ]]; then DEPLOY_USER="$ENV_DEPLOY_USER"; fi
|
||||
if [[ -n "${ENV_DEPLOY_PATH:-}" ]]; then DEPLOY_PATH="$ENV_DEPLOY_PATH"; fi
|
||||
if [[ -n "${ENV_WWW_USER:-}" ]]; then WWW_USER="$ENV_WWW_USER"; fi
|
||||
if [[ -n "${ENV_NPM_CACHE_DIR:-}" ]]; then NPM_CACHE_DIR="$ENV_NPM_CACHE_DIR"; fi
|
||||
if [[ -n "${ENV_GITEA_REMOTE:-}" ]]; then GITEA_REMOTE="$ENV_GITEA_REMOTE"; fi
|
||||
if [[ -n "${ENV_GITEA_URL:-}" ]]; then GITEA_URL="$ENV_GITEA_URL"; fi
|
||||
if [[ -n "${ENV_GITHUB_REMOTE:-}" ]]; then GITHUB_REMOTE="$ENV_GITHUB_REMOTE"; fi
|
||||
if [[ -n "${ENV_GITHUB_URL:-}" ]]; then GITHUB_URL="$ENV_GITHUB_URL"; fi
|
||||
if [[ -n "${ENV_PUSH_BRANCH:-}" ]]; then PUSH_BRANCH="$ENV_PUSH_BRANCH"; fi
|
||||
|
||||
SKIP_SYNC=0
|
||||
SKIP_COMPOSER=0
|
||||
@@ -22,8 +126,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,17 +148,21 @@ 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:
|
||||
DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, NPM_CACHE_DIR, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
|
||||
CONFIG_FILE points to a TOML file (default: ./config.toml). The script reads [deploy] keys.
|
||||
CONFIG_FILE, DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, NPM_CACHE_DIR, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -107,10 +216,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 +242,10 @@ while [[ $# -gt 0 ]]; do
|
||||
PUSH_GITHUB=1
|
||||
shift
|
||||
;;
|
||||
--no-push-github)
|
||||
PUSH_GITHUB=0
|
||||
shift
|
||||
;;
|
||||
--github-remote)
|
||||
GITHUB_REMOTE="$2"
|
||||
shift 2
|
||||
@@ -149,24 +272,73 @@ 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
|
||||
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]}"
|
||||
@@ -179,51 +351,42 @@ bump_version() {
|
||||
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."
|
||||
echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$new" == "$current" ]]; then
|
||||
echo "VERSION is already '$current'. Use --version to set a new value."
|
||||
if [[ "$new" == "$current" ]]; then
|
||||
echo "VERSION is already '$current'. Use --version to set a new value." >&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"
|
||||
printf 'VERSION=%s\n' "$new" > VERSION
|
||||
sed -i -E "s|JABALI_VERSION=\"\\$\\{JABALI_VERSION:-[^}]+\\}\"|JABALI_VERSION=\"\\\${JABALI_VERSION:-$new}\"|" install.sh
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
prepare_push() {
|
||||
ensure_clean_worktree
|
||||
bump_version
|
||||
}
|
||||
|
||||
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 +438,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 +453,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
|
||||
|
||||
|
||||
98
tests/Feature/ApiSecurityHardeningTest.php
Normal file
98
tests/Feature/ApiSecurityHardeningTest.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\RunGitDeployment;
|
||||
use App\Models\Domain;
|
||||
use App\Models\GitDeployment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiSecurityHardeningTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_git_webhook_rejects_unsigned_request_on_tokenless_route(): void
|
||||
{
|
||||
Bus::fake();
|
||||
$deployment = $this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user