diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d1a3242 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ball Studios (https://git.balls.studio) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7d48ff9..ca47196 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,82 @@ -# Pterodactyl Addon +# Pterodactyl Advanced Admin Addons -A custom addon for the Pterodactyl Panel. +> **Author:** Ball Studios +> **Repository:** https://git.balls.studio/BallStudios/pterodactyl_addon +> **License:** MIT +> **Target:** Pterodactyl Panel v1.12.4 + Wings latest stable -## Setup +A production-ready, modular addon suite for Pterodactyl Panel adding four major features: -_Coming soon._ +| Module | Description | +|--------|-------------| +| 🌐 **Network Traffic Dashboard** | Real-time + historical traffic, port breakdown, container flow graph | +| πŸ” **Role-Based Server Permissions** | Reusable roles with full deny/allow resolution, server-level overrides | +| πŸ“ **File Revision History** | Compressed file diffs, restore, diff viewer, audit log | +| ⌨️ **Console Command Autocomplete** | Minecraft command library, live player tracking, custom definitions | + +--- + +## ⚑ One-Line Install + +Run this as **root** on your Pterodactyl server: + +```bash +bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/install.sh) +``` + +With a custom panel path (if not `/var/www/pterodactyl`): + +```bash +bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/install.sh) /path/to/pterodactyl +``` + +### Update + +```bash +bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/update.sh) +``` + +### Uninstall + +```bash +bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/uninstall.sh) +``` + +> [!NOTE] +> The installer backs up all patched core files before making any changes. The backup path is printed at the end of installation. + +--- + +## Manual Install + +If you prefer manual control, see [docs/INSTALL.md](docs/INSTALL.md) for step-by-step instructions. + +--- + +## Documentation + +- [Installation Guide](docs/INSTALL.md) +- [Update Guide](docs/UPDATE.md) +- [Uninstall Guide](docs/UNINSTALL.md) +- [Configuration Reference](docs/CONFIG.md) +- [API Reference](docs/API.md) +- [Security Notes](docs/SECURITY.md) +- [Troubleshooting](docs/TROUBLESHOOTING.md) + +--- + +## Requirements + +| Requirement | Version | +|---|---| +| Pterodactyl Panel | v1.12.x | +| PHP | 8.1+ | +| Node.js | 22.x | +| Yarn | 1.x | +| MySQL/MariaDB | 8.0+ / 10.4+ | + +--- + +## License + +MIT Β© Ball Studios β€” https://git.balls.studio diff --git a/app/Addons/AdvancedAdmin/AddonServiceProvider.php b/app/Addons/AdvancedAdmin/AddonServiceProvider.php new file mode 100644 index 0000000..a09c4a4 --- /dev/null +++ b/app/Addons/AdvancedAdmin/AddonServiceProvider.php @@ -0,0 +1,119 @@ + + * @license MIT + * + * Register this provider by adding ONE line to app/Providers/AppServiceProvider.php: + * $this->app->register(\Pterodactyl\Addons\AdvancedAdmin\AddonServiceProvider::class); + * + * See patches/PATCHES.md for full patch instructions. + */ + +namespace Pterodactyl\Addons\AdvancedAdmin; + +use Illuminate\Support\ServiceProvider; +use Illuminate\Console\Scheduling\Schedule; +use Pterodactyl\Addons\AdvancedAdmin\Jobs\Network\CollectNetworkMetricsJob; +use Pterodactyl\Addons\AdvancedAdmin\Jobs\Network\DownsampleNetworkMetricsJob; +use Pterodactyl\Addons\AdvancedAdmin\Jobs\Network\PruneNetworkMetricsJob; +use Pterodactyl\Addons\AdvancedAdmin\Jobs\Revisions\PruneFileRevisionsJob; +use Pterodactyl\Addons\AdvancedAdmin\Jobs\Console\ExpireStalePlayersJob; +use Pterodactyl\Addons\AdvancedAdmin\Listeners\Console\ConsoleOutputListener; +use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\PermissionResolver; + +class AddonServiceProvider extends ServiceProvider +{ + /** + * Register addon bindings. + */ + public function register(): void + { + // Merge addon config (won't overwrite user's published config) + $this->mergeConfigFrom( + __DIR__ . '/../../config/advanced-admin.php', + 'advanced-admin' + ); + + // Bind the PermissionResolver as a singleton for performance + $this->app->singleton(PermissionResolver::class); + } + + /** + * Bootstrap addon services. + */ + public function boot(): void + { + // ── Routes ──────────────────────────────────────────────────────────── + $this->loadRoutesFrom(__DIR__ . '/../../routes/addon-client.php'); + $this->loadRoutesFrom(__DIR__ . '/../../routes/addon-application.php'); + + // ── Migrations ──────────────────────────────────────────────────────── + $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations/addon'); + + // ── Scheduled Jobs ──────────────────────────────────────────────────── + $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { + if (config('advanced-admin.network.enabled')) { + $schedule->job(CollectNetworkMetricsJob::class) + ->everyTenSeconds() + ->withoutOverlapping(5) + ->onOneServer(); + + $schedule->job(DownsampleNetworkMetricsJob::class) + ->hourly() + ->withoutOverlapping(30) + ->onOneServer(); + + $schedule->job(PruneNetworkMetricsJob::class) + ->daily() + ->withoutOverlapping(60) + ->onOneServer(); + } + + if (config('advanced-admin.revisions.enabled')) { + $schedule->job(PruneFileRevisionsJob::class) + ->daily() + ->withoutOverlapping(60) + ->onOneServer(); + } + + if (config('advanced-admin.console.enabled')) { + $schedule->job(ExpireStalePlayersJob::class) + ->everyFiveMinutes() + ->withoutOverlapping(4); + } + }); + + // ── Event Listeners ─────────────────────────────────────────────────── + // Console output listener (parses join/leave for player cache) + // Hooked to Pterodactyl's existing console output events if they exist. + // Falls back gracefully if event is not fired by this panel version. + if (class_exists(\Pterodactyl\Events\Server\ConsoleDataReceived::class)) { + $this->app['events']->listen( + \Pterodactyl\Events\Server\ConsoleDataReceived::class, + ConsoleOutputListener::class + ); + } + + // ── Artisan Commands ────────────────────────────────────────────────── + if ($this->app->runningInConsole()) { + $this->commands([ + Console\Commands\AddonPatchCommand::class, + Console\Commands\AddonInstallCommand::class, + Console\Commands\AddonUninstallCommand::class, + Console\Commands\CollectNetworkCommand::class, + ]); + } + + // ── Publishable Assets ──────────────────────────────────────────────── + $this->publishes([ + __DIR__ . '/../../config/advanced-admin.php' => config_path('advanced-admin.php'), + ], 'advanced-admin-config'); + + $this->publishes([ + __DIR__ . '/../../config/addon-commands' => base_path('config/addon-commands'), + ], 'advanced-admin-commands'); + } +} diff --git a/app/Addons/AdvancedAdmin/Http/Controllers/Admin/RoleController.php b/app/Addons/AdvancedAdmin/Http/Controllers/Admin/RoleController.php new file mode 100644 index 0000000..b65af8b --- /dev/null +++ b/app/Addons/AdvancedAdmin/Http/Controllers/Admin/RoleController.php @@ -0,0 +1,71 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Admin; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; +use Pterodactyl\Addons\AdvancedAdmin\Http\Requests\Roles\CreateRoleRequest; +use Pterodactyl\Addons\AdvancedAdmin\Http\Requests\Roles\UpdateRoleRequest; +use Pterodactyl\Addons\AdvancedAdmin\Http\Resources\RoleResource; +use Pterodactyl\Addons\AdvancedAdmin\Models\Role; +use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\RoleService; + +class RoleController extends Controller +{ + public function __construct(private readonly RoleService $roleService) {} + + /** GET /api/application/addons/roles */ + public function index(Request $request): JsonResponse + { + $this->authorizeAdmin($request); + + $roles = Role::with('permissions') + ->orderByDesc('priority') + ->paginate(50); + + return RoleResource::collection($roles)->response(); + } + + /** GET /api/application/addons/roles/{role} */ + public function show(Request $request, Role $role): JsonResponse + { + $this->authorizeAdmin($request); + return (new RoleResource($role->load('permissions')))->response(); + } + + /** POST /api/application/addons/roles */ + public function store(CreateRoleRequest $request): JsonResponse + { + $this->authorizeAdmin($request); + $role = $this->roleService->create($request->validated(), $request->user()->id); + return (new RoleResource($role->load('permissions')))->response()->setStatusCode(201); + } + + /** PATCH /api/application/addons/roles/{role} */ + public function update(UpdateRoleRequest $request, Role $role): JsonResponse + { + $this->authorizeAdmin($request); + $updated = $this->roleService->update($role, $request->validated(), $request->user()->id); + return (new RoleResource($updated->load('permissions')))->response(); + } + + /** DELETE /api/application/addons/roles/{role} */ + public function destroy(Request $request, Role $role): JsonResponse + { + $this->authorizeAdmin($request); + $force = (bool) $request->boolean('force', false); + $this->roleService->delete($role, $request->user()->id, $force); + return response()->json(['deleted' => true]); + } + + private function authorizeAdmin(Request $request): void + { + abort_unless($request->user() && $request->user()->root_admin, 403, 'Admin access required.'); + } +} diff --git a/app/Addons/AdvancedAdmin/Http/Controllers/Client/AutocompleteController.php b/app/Addons/AdvancedAdmin/Http/Controllers/Client/AutocompleteController.php new file mode 100644 index 0000000..4b5797e --- /dev/null +++ b/app/Addons/AdvancedAdmin/Http/Controllers/Client/AutocompleteController.php @@ -0,0 +1,66 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; +use Pterodactyl\Addons\AdvancedAdmin\Models\ConsolePlayerCache; +use Pterodactyl\Addons\AdvancedAdmin\Services\Console\AutocompleteEngine; +use Pterodactyl\Models\Server; + +class AutocompleteController extends Controller +{ + public function __construct(private readonly AutocompleteEngine $engine) {} + + /** GET /api/client/servers/{server}/addons/console/autocomplete?q= */ + public function suggest(Request $request, Server $server): JsonResponse + { + $this->requireAccess($request, $server); + + $query = (string) $request->get('q', ''); + $suggestions = $this->engine->suggest( + $server->id, + $server->node_id, + $server->egg_id, + $query, + ); + + return response()->json(['data' => $suggestions]); + } + + /** GET /api/client/servers/{server}/addons/console/players */ + public function players(Request $request, Server $server): JsonResponse + { + $this->requireAccess($request, $server); + + $players = ConsolePlayerCache::query() + ->where('server_id', $server->id) + ->where('last_seen_at', '>=', now()->subMinutes( + config('advanced-admin.console.player_cache_ttl_minutes', 30) + )) + ->orderBy('player_name') + ->pluck('player_name'); + + return response()->json(['data' => $players]); + } + + private function requireAccess(Request $request, Server $server): void + { + $user = $request->user(); + abort_unless($user, 401); + + $isOwner = $server->owner_id === $user->id; + $isAdmin = $user->root_admin; + + if (!$isOwner && !$isAdmin) { + $subuser = $server->subusers()->where('user_id', $user->id)->first(); + abort_unless($subuser && in_array('control.console', $subuser->permissions ?? [], true), 403); + } + } +} diff --git a/app/Addons/AdvancedAdmin/Http/Controllers/Client/FileRevisionController.php b/app/Addons/AdvancedAdmin/Http/Controllers/Client/FileRevisionController.php new file mode 100644 index 0000000..e7f0cba --- /dev/null +++ b/app/Addons/AdvancedAdmin/Http/Controllers/Client/FileRevisionController.php @@ -0,0 +1,150 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Pterodactyl\Addons\AdvancedAdmin\Models\FileRevision; +use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\DiffService; +use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionService; +use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionStorageService; +use Pterodactyl\Models\Server; + +class FileRevisionController extends Controller +{ + public function __construct( + private readonly RevisionService $revisionService, + private readonly DiffService $diffService, + private readonly RevisionStorageService $storage, + ) {} + + /** GET /api/client/servers/{server}/addons/files/revisions?path= */ + public function index(Request $request, Server $server): JsonResponse + { + $this->requirePermission($request, $server, 'file.read'); + + $path = $request->string('path', '')->toString(); + abort_if(empty($path), 422, 'path parameter is required.'); + + $path = $this->storage->guardFilePath($path); + $pathHash = $this->storage->pathHash($server->id, $path); + + $revisions = FileRevision::query() + ->where('server_id', $server->id) + ->where('path_hash', $pathHash) + ->with('author:id,username,email') + ->orderByDesc('revision_number') + ->paginate(25); + + return response()->json($revisions); + } + + /** GET /api/client/servers/{server}/addons/files/revisions/{revision} */ + public function show(Request $request, Server $server, FileRevision $revision): JsonResponse + { + $this->requirePermission($request, $server, 'file.read'); + $this->assertBelongsToServer($revision, $server); + + return response()->json(['data' => $revision->load('author:id,username,email')]); + } + + /** GET /api/client/servers/{server}/addons/files/revisions/{revision}/diff */ + public function diff(Request $request, Server $server, FileRevision $revision): JsonResponse + { + $this->requirePermission($request, $server, 'file.read'); + $this->assertBelongsToServer($revision, $server); + + $content = $this->storage->retrieve($revision->storage_key); + + // If binary, return a placeholder diff + if ($this->storage->isBinary($content)) { + return response()->json(['binary' => true, 'lines' => []]); + } + + // Fetch previous revision for comparison + $previous = FileRevision::query() + ->where('server_id', $server->id) + ->where('path_hash', $revision->path_hash) + ->where('revision_number', $revision->revision_number - 1) + ->first(); + + $oldContent = $previous ? $this->storage->retrieve($previous->storage_key) : ''; + + $lines = $this->diffService->diff($oldContent, $content, basename($revision->file_path)); + return response()->json(['binary' => false, 'lines' => $lines]); + } + + /** GET /api/client/servers/{server}/addons/files/revisions/{revision}/download */ + public function download(Request $request, Server $server, FileRevision $revision): StreamedResponse + { + $this->requirePermission($request, $server, 'file.read'); + $this->assertBelongsToServer($revision, $server); + + $content = $this->storage->retrieve($revision->storage_key); + $filename = basename($revision->file_path) . ".rev{$revision->revision_number}"; + + return response()->streamDownload(function () use ($content) { + echo $content; + }, $filename, ['Content-Type' => 'application/octet-stream']); + } + + /** POST /api/client/servers/{server}/addons/files/revisions/{revision}/restore */ + public function restore(Request $request, Server $server, FileRevision $revision): JsonResponse + { + $this->requirePermission($request, $server, 'file.update'); + $this->assertBelongsToServer($revision, $server); + + $content = $this->revisionService->restore($revision, $request->user()->id); + + return response()->json([ + 'restored' => true, + 'content' => $content, + 'revision' => $revision->revision_number, + ]); + } + + /** DELETE /api/client/servers/{server}/addons/files/revisions/{revision} */ + public function destroy(Request $request, Server $server, FileRevision $revision): JsonResponse + { + // Only admins can manually delete revisions + abort_unless($request->user()?->root_admin, 403, 'Admin access required.'); + $this->assertBelongsToServer($revision, $server); + + $this->revisionService->delete($revision); + return response()->json(['deleted' => true]); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private function requirePermission(Request $request, Server $server, string $permission): void + { + $user = $request->user(); + abort_unless($user, 401); + + $isOwner = $server->owner_id === $user->id; + $isAdmin = $user->root_admin; + + if (!$isOwner && !$isAdmin) { + $subuser = $server->subusers()->where('user_id', $user->id)->first(); + abort_unless( + $subuser && in_array($permission, $subuser->permissions ?? [], true), + 403, + "Permission '{$permission}' required." + ); + } + } + + private function assertBelongsToServer(FileRevision $revision, Server $server): void + { + abort_unless($revision->server_id === $server->id, 404); + } +} diff --git a/app/Addons/AdvancedAdmin/Http/Controllers/Client/NetworkMetricsController.php b/app/Addons/AdvancedAdmin/Http/Controllers/Client/NetworkMetricsController.php new file mode 100644 index 0000000..ffb0703 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Http/Controllers/Client/NetworkMetricsController.php @@ -0,0 +1,116 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\DB; +use Pterodactyl\Models\Server; + +class NetworkMetricsController extends Controller +{ + /** GET /api/client/servers/{server}/addons/network/live */ + public function live(Request $request, Server $server): JsonResponse + { + $this->requireAccess($request, $server); + + // Latest raw metric (most recent 30 seconds) + $latest = DB::table('addon_network_metrics') + ->where('server_id', $server->id) + ->where('resolution', 'raw') + ->where('recorded_at', '>=', now()->subSeconds(30)) + ->orderByDesc('recorded_at') + ->select('rx_bytes', 'tx_bytes', 'recorded_at') + ->first(); + + return response()->json(['data' => $latest]); + } + + /** GET /api/client/servers/{server}/addons/network/history */ + public function history(Request $request, Server $server): JsonResponse + { + $this->requireAccess($request, $server); + + $resolution = $request->get('resolution', '5m'); + abort_unless(in_array($resolution, ['raw', '5m', '1h', '1d'], true), 422, 'Invalid resolution.'); + + $hours = match ($resolution) { + 'raw' => 1, + '5m' => 24, + '1h' => 168, + '1d' => 8760, + }; + + $metrics = DB::table('addon_network_metrics') + ->where('server_id', $server->id) + ->where('resolution', $resolution) + ->where('recorded_at', '>=', now()->subHours($hours)) + ->orderBy('recorded_at') + ->select('rx_bytes', 'tx_bytes', 'recorded_at') + ->get(); + + return response()->json(['data' => $metrics]); + } + + /** GET /api/client/servers/{server}/addons/network/ports */ + public function ports(Request $request, Server $server): JsonResponse + { + $this->requireAccess($request, $server); + + if (!config('advanced-admin.network.iptables_enabled')) { + return response()->json(['data' => [], 'unavailable' => true, 'reason' => 'iptables collection not enabled']); + } + + $ports = DB::table('addon_network_port_metrics') + ->where('server_id', $server->id) + ->where('resolution', '5m') + ->where('recorded_at', '>=', now()->subHour()) + ->groupBy('port', 'protocol') + ->select('port', 'protocol', DB::raw('SUM(rx_bytes) as rx_bytes'), DB::raw('SUM(tx_bytes) as tx_bytes')) + ->orderByDesc('rx_bytes') + ->limit(50) + ->get(); + + return response()->json(['data' => $ports]); + } + + /** GET /api/client/servers/{server}/addons/network/flows */ + public function flows(Request $request, Server $server): JsonResponse + { + $this->requireAccess($request, $server); + + $flows = DB::table('addon_network_flows') + ->where(function ($q) use ($server) { + $q->where('src_server_id', $server->id) + ->orWhere('dst_server_id', $server->id); + }) + ->where('recorded_at', '>=', now()->subHours(24)) + ->select('src_server_id', 'dst_server_id', DB::raw('SUM(bytes) as bytes'), DB::raw('SUM(packets) as packets')) + ->groupBy('src_server_id', 'dst_server_id') + ->orderByDesc('bytes') + ->limit(50) + ->get(); + + return response()->json(['data' => $flows]); + } + + private function requireAccess(Request $request, Server $server): void + { + $user = $request->user(); + abort_unless($user, 401); + $isOwner = $server->owner_id === $user->id; + $isAdmin = $user->root_admin; + if (!$isOwner && !$isAdmin) { + $subuser = $server->subusers()->where('user_id', $user->id)->first(); + abort_unless($subuser, 403); + } + } +} diff --git a/app/Addons/AdvancedAdmin/Http/Controllers/Client/ServerRoleController.php b/app/Addons/AdvancedAdmin/Http/Controllers/Client/ServerRoleController.php new file mode 100644 index 0000000..70bcba1 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Http/Controllers/Client/ServerRoleController.php @@ -0,0 +1,140 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; +use Pterodactyl\Addons\AdvancedAdmin\Http\Resources\RoleResource; +use Pterodactyl\Addons\AdvancedAdmin\Http\Resources\EffectivePermissionResource; +use Pterodactyl\Addons\AdvancedAdmin\Models\ServerRoleAssignment; +use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\PermissionResolver; +use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\RoleService; +use Pterodactyl\Models\Server; + +class ServerRoleController extends Controller +{ + public function __construct( + private readonly RoleService $roleService, + private readonly PermissionResolver $resolver, + ) {} + + /** GET /api/client/servers/{server}/addons/roles/users */ + public function listUsers(Request $request, Server $server): JsonResponse + { + $this->authorizeServerAdmin($request, $server); + + $assignments = ServerRoleAssignment::query() + ->where('server_id', $server->id) + ->with(['user', 'role.permissions']) + ->get() + ->groupBy('user_id') + ->map(function ($items) { + $user = $items->first()->user; + $roles = $items->pluck('role'); + return ['user' => $user, 'roles' => $roles]; + }) + ->values(); + + return response()->json(['data' => $assignments]); + } + + /** POST /api/client/servers/{server}/addons/roles/assign */ + public function assign(Request $request, Server $server): JsonResponse + { + $this->authorizeServerAdmin($request, $server); + + $data = $request->validate([ + 'user_id' => 'required|integer|exists:users,id', + 'role_id' => 'required|integer|exists:addon_roles,id', + ]); + + $assignment = $this->roleService->assignToUser( + $server->id, + $data['user_id'], + $data['role_id'], + $request->user()->id, + ); + + return response()->json(['data' => $assignment], 201); + } + + /** DELETE /api/client/servers/{server}/addons/roles/assign */ + public function unassign(Request $request, Server $server): JsonResponse + { + $this->authorizeServerAdmin($request, $server); + + $data = $request->validate([ + 'user_id' => 'required|integer|exists:users,id', + 'role_id' => 'required|integer|exists:addon_roles,id', + ]); + + $this->roleService->unassignFromUser( + $server->id, + $data['user_id'], + $data['role_id'], + $request->user()->id, + ); + + return response()->json(['deleted' => true]); + } + + /** GET /api/client/servers/{server}/addons/permissions/effective/{userId} */ + public function effectivePermissions(Request $request, Server $server, int $targetUserId): JsonResponse + { + $this->authorizeServerAdmin($request, $server); + + $effective = $this->resolver->resolveAll( + $targetUserId, + $server->id, + RoleService::KNOWN_PERMISSIONS + ); + + return response()->json(['data' => $effective]); + } + + /** PUT /api/client/servers/{server}/addons/permissions/override */ + public function setOverride(Request $request, Server $server): JsonResponse + { + $this->authorizeServerAdmin($request, $server); + + $data = $request->validate([ + 'user_id' => 'required|integer|exists:users,id', + 'permission' => 'required|string|max:100', + 'value' => 'required|in:allow,deny,unset', + ]); + + $override = $this->roleService->setOverride( + $server->id, + $data['user_id'], + $data['permission'], + $data['value'], + $request->user()->id, + ); + + return response()->json(['data' => $override]); + } + + private function authorizeServerAdmin(Request $request, Server $server): void + { + $user = $request->user(); + abort_unless($user, 401); + + $isOwner = $server->owner_id === $user->id; + $isAdmin = $user->root_admin; + + // Subusers with user.update can also manage roles on their server + $isSubuserAdmin = false; + if (!$isOwner && !$isAdmin) { + $subuser = $server->subusers()->where('user_id', $user->id)->first(); + $isSubuserAdmin = $subuser && in_array('user.update', $subuser->permissions ?? [], true); + } + + abort_unless($isOwner || $isAdmin || $isSubuserAdmin, 403, 'Insufficient privileges.'); + } +} diff --git a/app/Addons/AdvancedAdmin/Http/Requests/Roles/CreateRoleRequest.php b/app/Addons/AdvancedAdmin/Http/Requests/Roles/CreateRoleRequest.php new file mode 100644 index 0000000..f9c13ab --- /dev/null +++ b/app/Addons/AdvancedAdmin/Http/Requests/Roles/CreateRoleRequest.php @@ -0,0 +1,31 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Http\Requests\Roles; + +use Illuminate\Foundation\Http\FormRequest; + +class CreateRoleRequest extends FormRequest +{ + public function authorize(): bool + { + return $this->user() && $this->user()->root_admin; + } + + public function rules(): array + { + return [ + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:500', + 'color' => 'nullable|string|regex:/^#[0-9a-fA-F]{6}$/', + 'priority' => 'nullable|integer|min:0|max:9999', + 'is_default' => 'nullable|boolean', + 'permissions' => 'nullable|array', + 'permissions.*' => 'in:allow,deny', + ]; + } +} diff --git a/app/Addons/AdvancedAdmin/Http/Requests/Roles/UpdateRoleRequest.php b/app/Addons/AdvancedAdmin/Http/Requests/Roles/UpdateRoleRequest.php new file mode 100644 index 0000000..c20b990 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Http/Requests/Roles/UpdateRoleRequest.php @@ -0,0 +1,31 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Http\Requests\Roles; + +use Illuminate\Foundation\Http\FormRequest; + +class UpdateRoleRequest extends FormRequest +{ + public function authorize(): bool + { + return $this->user() && $this->user()->root_admin; + } + + public function rules(): array + { + return [ + 'name' => 'sometimes|string|max:100', + 'description' => 'sometimes|nullable|string|max:500', + 'color' => 'sometimes|nullable|string|regex:/^#[0-9a-fA-F]{6}$/', + 'priority' => 'sometimes|integer|min:0|max:9999', + 'is_default' => 'sometimes|boolean', + 'permissions' => 'sometimes|array', + 'permissions.*' => 'in:allow,deny', + ]; + } +} diff --git a/app/Addons/AdvancedAdmin/Http/Resources/RoleResource.php b/app/Addons/AdvancedAdmin/Http/Resources/RoleResource.php new file mode 100644 index 0000000..53ce997 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Http/Resources/RoleResource.php @@ -0,0 +1,31 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Http\Resources; + +use Illuminate\Http\Resources\Json\JsonResource; + +class RoleResource extends JsonResource +{ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'color' => $this->color, + 'priority' => $this->priority, + 'is_default' => $this->is_default, + 'in_use' => $this->whenLoaded('assignments', fn () => $this->assignments->count() > 0), + 'permissions' => $this->whenLoaded('permissions', function () { + return $this->permissions->pluck('value', 'permission'); + }), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Addons/AdvancedAdmin/Jobs/Network/CollectNetworkMetricsJob.php b/app/Addons/AdvancedAdmin/Jobs/Network/CollectNetworkMetricsJob.php new file mode 100644 index 0000000..172973a --- /dev/null +++ b/app/Addons/AdvancedAdmin/Jobs/Network/CollectNetworkMetricsJob.php @@ -0,0 +1,53 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Jobs\Network; + +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\DB; +use Pterodactyl\Addons\AdvancedAdmin\Services\Network\DockerStatsCollector; +use Pterodactyl\Models\Node; + +class CollectNetworkMetricsJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public int $tries = 1; + public int $timeout = 30; + + public function handle(DockerStatsCollector $collector): void + { + if (!config('advanced-admin.network.enabled')) return; + + $nodes = Node::all(); + $now = now(); + + foreach ($nodes as $node) { + $stats = $collector->collectForNode($node); + if (empty($stats)) continue; + + // Batch insert for efficiency + $rows = array_map(fn ($s) => [ + 'server_id' => $s['server_id'], + 'node_id' => $s['node_id'], + 'rx_bytes' => $s['rx_bytes'], + 'tx_bytes' => $s['tx_bytes'], + 'resolution' => 'raw', + 'recorded_at' => $now, + 'created_at' => $now, + ], $stats); + + DB::table('addon_network_metrics')->insert($rows); + } + } +} diff --git a/app/Addons/AdvancedAdmin/Models/FileRevision.php b/app/Addons/AdvancedAdmin/Models/FileRevision.php new file mode 100644 index 0000000..fd3c923 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Models/FileRevision.php @@ -0,0 +1,51 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * @property int $id + * @property int $server_id + * @property string $file_path + * @property string $path_hash + * @property string $content_hash + * @property int $revision_number + * @property string $storage_key + * @property string $storage_type + * @property int $size_bytes + * @property int $compressed_size + * @property int $author_id + * @property string $change_summary + */ +class FileRevision extends Model +{ + protected $table = 'addon_file_revisions'; + public $timestamps = false; + + protected $fillable = [ + 'server_id', 'file_path', 'path_hash', 'content_hash', + 'revision_number', 'storage_key', 'storage_type', + 'size_bytes', 'compressed_size', 'author_id', 'change_summary', + ]; + + protected $casts = [ + 'created_at' => 'datetime', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(\Pterodactyl\Models\Server::class, 'server_id'); + } + + public function author(): BelongsTo + { + return $this->belongsTo(\Pterodactyl\Models\User::class, 'author_id'); + } +} diff --git a/app/Addons/AdvancedAdmin/Models/FileRevisionStorageUsage.php b/app/Addons/AdvancedAdmin/Models/FileRevisionStorageUsage.php new file mode 100644 index 0000000..f6f85bb --- /dev/null +++ b/app/Addons/AdvancedAdmin/Models/FileRevisionStorageUsage.php @@ -0,0 +1,20 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Models; + +use Illuminate\Database\Eloquent\Model; + +class FileRevisionStorageUsage extends Model +{ + protected $table = 'addon_file_revision_storage_usage'; + protected $primaryKey = 'server_id'; + public $incrementing = false; + public $timestamps = false; + + protected $fillable = ['server_id', 'total_bytes']; +} diff --git a/app/Addons/AdvancedAdmin/Models/Role.php b/app/Addons/AdvancedAdmin/Models/Role.php new file mode 100644 index 0000000..2f34836 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Models/Role.php @@ -0,0 +1,73 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * @property int $id + * @property string $name + * @property string $description + * @property string $color + * @property int $priority + * @property bool $is_default + * @property int $created_by + */ +class Role extends Model +{ + protected $table = 'addon_roles'; + + protected $fillable = [ + 'name', + 'description', + 'color', + 'priority', + 'is_default', + 'created_by', + ]; + + protected $casts = [ + 'priority' => 'integer', + 'is_default' => 'boolean', + ]; + + public function permissions(): HasMany + { + return $this->hasMany(RolePermission::class, 'role_id'); + } + + public function assignments(): HasMany + { + return $this->hasMany(ServerRoleAssignment::class, 'role_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(\Pterodactyl\Models\User::class, 'created_by'); + } + + /** + * Check if this role is still assigned anywhere. + */ + public function isInUse(): bool + { + return $this->assignments()->exists(); + } + + /** + * Get permission value for a given key. + * Returns 'allow', 'deny', or null (not set). + */ + public function getPermission(string $permission): ?string + { + $perm = $this->permissions()->where('permission', $permission)->first(); + return $perm?->value; + } +} diff --git a/app/Addons/AdvancedAdmin/Models/RoleAuditLog.php b/app/Addons/AdvancedAdmin/Models/RoleAuditLog.php new file mode 100644 index 0000000..d1ab123 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Models/RoleAuditLog.php @@ -0,0 +1,52 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Models; + +use Illuminate\Database\Eloquent\Model; + +class RoleAuditLog extends Model +{ + protected $table = 'addon_role_audit_log'; + public $timestamps = false; + + protected $fillable = [ + 'actor_id', + 'action', + 'target_type', + 'target_id', + 'payload', + 'ip_address', + ]; + + protected $casts = [ + 'payload' => 'array', + 'created_at' => 'datetime', + ]; + + /** + * Write a structured audit entry. + */ + public static function record( + ?int $actorId, + string $action, + string $targetType = '', + ?int $targetId = null, + array $payload = [], + string $ip = '' + ): void { + static::create([ + 'actor_id' => $actorId, + 'action' => $action, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'payload' => $payload, + 'ip_address' => $ip ?: request()->ip(), + 'created_at' => now(), + ]); + } +} diff --git a/app/Addons/AdvancedAdmin/Models/RolePermission.php b/app/Addons/AdvancedAdmin/Models/RolePermission.php new file mode 100644 index 0000000..63df5c2 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Models/RolePermission.php @@ -0,0 +1,23 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +class RolePermission extends Model +{ + protected $table = 'addon_role_permissions'; + + protected $fillable = ['role_id', 'permission', 'value']; + + public function role(): BelongsTo + { + return $this->belongsTo(Role::class, 'role_id'); + } +} diff --git a/app/Addons/AdvancedAdmin/Models/ServerPermissionOverride.php b/app/Addons/AdvancedAdmin/Models/ServerPermissionOverride.php new file mode 100644 index 0000000..48426aa --- /dev/null +++ b/app/Addons/AdvancedAdmin/Models/ServerPermissionOverride.php @@ -0,0 +1,21 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Models; + +use Illuminate\Database\Eloquent\Model; + +class ServerPermissionOverride extends Model +{ + protected $table = 'addon_server_permission_overrides'; + + protected $fillable = ['server_id', 'user_id', 'permission', 'value', 'set_by']; + + public function isAllow(): bool { return $this->value === 'allow'; } + public function isDeny(): bool { return $this->value === 'deny'; } + public function isUnset(): bool { return $this->value === 'unset'; } +} diff --git a/app/Addons/AdvancedAdmin/Models/ServerRoleAssignment.php b/app/Addons/AdvancedAdmin/Models/ServerRoleAssignment.php new file mode 100644 index 0000000..322c558 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Models/ServerRoleAssignment.php @@ -0,0 +1,33 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +class ServerRoleAssignment extends Model +{ + protected $table = 'addon_server_role_assignments'; + + protected $fillable = ['server_id', 'user_id', 'role_id', 'assigned_by']; + + public function role(): BelongsTo + { + return $this->belongsTo(Role::class, 'role_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(\Pterodactyl\Models\User::class, 'user_id'); + } + + public function server(): BelongsTo + { + return $this->belongsTo(\Pterodactyl\Models\Server::class, 'server_id'); + } +} diff --git a/app/Addons/AdvancedAdmin/Services/Console/AutocompleteEngine.php b/app/Addons/AdvancedAdmin/Services/Console/AutocompleteEngine.php new file mode 100644 index 0000000..d77a17c --- /dev/null +++ b/app/Addons/AdvancedAdmin/Services/Console/AutocompleteEngine.php @@ -0,0 +1,188 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Services\Console; + +use Illuminate\Support\Facades\Cache; +use Pterodactyl\Addons\AdvancedAdmin\Models\CommandDefinition; +use Pterodactyl\Addons\AdvancedAdmin\Models\ConsolePlayerCache; + +class AutocompleteEngine +{ + /** + * Generate suggestions for a given query on a server. + * + * @return array + */ + public function suggest(int $serverId, ?int $nodeId, ?int $eggId, string $query): array + { + $query = $this->sanitize($query); + if (strlen($query) < 1) return []; + + $definitions = $this->loadDefinitions($serverId, $nodeId, $eggId); + $players = $this->getOnlinePlayers($serverId); + + // Determine if we're suggesting a command name or an argument + $parts = explode(' ', $query); + + // No space yet β†’ suggest command names + if (count($parts) === 1) { + return $this->suggestCommands($query, $definitions); + } + + // Space present β†’ suggest arguments for the matched command + $commandName = strtolower($parts[0]); + return $this->suggestArguments($commandName, $parts, $definitions, $players); + } + + /** + * Sanitize query β€” prevent XSS/injection in rendered suggestions. + */ + private function sanitize(string $query): string + { + // Strip null bytes, control chars, HTML + $query = preg_replace('/[\x00-\x1F\x7F]/', '', $query); + $query = strip_tags($query); + return mb_substr(trim($query), 0, 256); + } + + private function suggestCommands(string $prefix, array $definitions): array + { + $prefix = strtolower($prefix); + $results = []; + $max = config('advanced-admin.console.max_suggestions', 10); + + foreach ($definitions as $def) { + $names = array_merge([$def['name']], $def['aliases'] ?? []); + foreach ($names as $name) { + if (str_starts_with(strtolower($name), $prefix)) { + $results[] = $this->formatSuggestion($def, $name); + if (count($results) >= $max) break 2; + } + } + } + + return $results; + } + + private function suggestArguments(string $cmd, array $parts, array $definitions, array $players): array + { + $definition = null; + foreach ($definitions as $def) { + $names = array_merge([$def['name']], $def['aliases'] ?? []); + if (in_array($cmd, array_map('strtolower', $names), true)) { + $definition = $def; + break; + } + } + if (!$definition) return []; + + $argIndex = count($parts) - 2; // 0-based index of current argument + $args = $definition['args'] ?? []; + + if (!isset($args[$argIndex])) return []; + + $argDef = $args[$argIndex]; + $partial = strtolower(end($parts)); + $max = config('advanced-admin.console.max_suggestions', 10); + $results = []; + + if ($argDef['type'] === 'player') { + foreach ($players as $player) { + if (str_starts_with(strtolower($player), $partial)) { + $results[] = ['command' => $player, 'description' => 'Online player', 'syntax' => '', 'dangerous' => false, 'plugin' => 'server']; + if (count($results) >= $max) break; + } + } + } elseif (!empty($argDef['suggestions'])) { + foreach ($argDef['suggestions'] as $s) { + if (str_starts_with(strtolower($s), $partial)) { + $results[] = ['command' => $s, 'description' => $argDef['name'] ?? '', 'syntax' => '', 'dangerous' => false, 'plugin' => '']; + if (count($results) >= $max) break; + } + } + } + + // Return parent command suggestion with full syntax + if (empty($results)) { + $results[] = $this->formatSuggestion($definition, $definition['name']); + } + + return $results; + } + + private function formatSuggestion(array $def, string $displayName): array + { + $syntax = '/' . $displayName; + foreach ($def['args'] ?? [] as $arg) { + $syntax .= $arg['required'] ? " <{$arg['name']}>" : " [{$arg['name']}]"; + } + return [ + 'command' => $displayName, + 'description' => $def['description'] ?? '', + 'syntax' => $syntax, + 'dangerous' => $def['dangerous'] ?? false, + 'danger_reason' => $def['dangerReason'] ?? null, + 'plugin' => $def['plugin'] ?? 'vanilla', + 'examples' => $def['examples'] ?? [], + ]; + } + + /** + * Load command definitions for this server (server-specific β†’ node β†’ egg β†’ global). + * Cached for 60 seconds per server. + */ + private function loadDefinitions(int $serverId, ?int $nodeId, ?int $eggId): array + { + $cacheKey = "addon_commands_{$serverId}_{$nodeId}_{$eggId}"; + return Cache::remember($cacheKey, 60, function () use ($serverId, $nodeId, $eggId) { + // DB custom definitions + $dbDefs = CommandDefinition::query() + ->where('is_active', true) + ->where(function ($q) use ($serverId, $nodeId, $eggId) { + $q->whereNull('server_id') + ->orWhere('server_id', $serverId); + }) + ->get() + ->map(fn ($d) => $d->definition) + ->toArray(); + + // Built-in JSON definitions from config/addon-commands/ + $builtIn = $this->loadBuiltInDefinitions(); + + return array_merge($builtIn, $dbDefs); + }); + } + + private function loadBuiltInDefinitions(): array + { + $path = config('advanced-admin.console.definitions_path', base_path('config/addon-commands')); + $files = glob($path . '/*.json') ?: []; + $defs = []; + foreach ($files as $file) { + $raw = file_get_contents($file); + if (!$raw) continue; + $decoded = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) continue; + $defs = array_merge($defs, is_array($decoded[0] ?? null) ? $decoded : [$decoded]); + } + return $defs; + } + + private function getOnlinePlayers(int $serverId): array + { + return ConsolePlayerCache::query() + ->where('server_id', $serverId) + ->where('last_seen_at', '>=', now()->subMinutes( + config('advanced-admin.console.player_cache_ttl_minutes', 30) + )) + ->pluck('player_name') + ->toArray(); + } +} diff --git a/app/Addons/AdvancedAdmin/Services/Network/DockerStatsCollector.php b/app/Addons/AdvancedAdmin/Services/Network/DockerStatsCollector.php new file mode 100644 index 0000000..bf26a09 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Services/Network/DockerStatsCollector.php @@ -0,0 +1,83 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Services\Network; + +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Pterodactyl\Models\Node; +use Pterodactyl\Models\Server; + +class DockerStatsCollector +{ + /** + * Collect network stats for all running servers on a node. + * Returns array of ['server_id' => int, 'rx_bytes' => int, 'tx_bytes' => int] + */ + public function collectForNode(Node $node): array + { + $results = []; + + $servers = Server::query() + ->where('node_id', $node->id) + ->whereNotNull('uuid') + ->get(); + + foreach ($servers as $server) { + try { + $stats = $this->fetchStats($node, $server->uuid); + if ($stats !== null) { + $results[] = [ + 'server_id' => $server->id, + 'node_id' => $node->id, + 'rx_bytes' => $stats['network']['rx_bytes'] ?? 0, + 'tx_bytes' => $stats['network']['tx_bytes'] ?? 0, + ]; + } + } catch (\Throwable $e) { + Log::warning("[AdvancedAdmin] Failed to collect stats for server {$server->uuid}: {$e->getMessage()}"); + } + } + + return $results; + } + + /** + * Fetch stats from Wings for a single server UUID. + * Uses the Wings Application API (node-level auth token). + * + * SSRF Prevention: URL is constructed only from trusted Node model data, + * never from user input. + */ + private function fetchStats(Node $node, string $serverUuid): ?array + { + // Validate UUID format before use + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $serverUuid)) { + return null; + } + + $scheme = $node->scheme; + $fqdn = $node->fqdn; + $port = $node->daemonListen; + + // Allowlist: only known node FQDNs are used (from DB, not from user input) + $url = "{$scheme}://{$fqdn}:{$port}/api/servers/{$serverUuid}/resources"; + + $response = Http::withToken($node->daemon_token_id) + ->withHeaders(['Authorization' => "Bearer {$node->daemon_token_id}"]) + ->timeout(5) + ->get($url); + + if (!$response->successful()) { + return null; + } + + return $response->json('resources'); + } +} diff --git a/app/Addons/AdvancedAdmin/Services/Revisions/DiffService.php b/app/Addons/AdvancedAdmin/Services/Revisions/DiffService.php new file mode 100644 index 0000000..ac74bcb --- /dev/null +++ b/app/Addons/AdvancedAdmin/Services/Revisions/DiffService.php @@ -0,0 +1,116 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions; + +class DiffService +{ + /** + * Generate a unified diff between old and new content. + * Returns array of lines with type: 'context' | 'added' | 'removed' | 'header' + */ + public function diff(string $oldContent, string $newContent, string $fileName = 'file'): array + { + if (class_exists(\SebastianBergmann\Diff\Differ::class)) { + return $this->diffViaSebastian($oldContent, $newContent, $fileName); + } + return $this->diffNative($oldContent, $newContent); + } + + /** + * Compute a structured diff using SebastianBergmann\Diff. + */ + private function diffViaSebastian(string $old, string $new, string $fileName): array + { + $differ = new \SebastianBergmann\Diff\Differ( + new \SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder("--- a/{$fileName}\n+++ b/{$fileName}\n") + ); + $rawDiff = $differ->diff($old, $new); + return $this->parseUnifiedDiff($rawDiff); + } + + /** + * Simple pure-PHP line-level diff (fallback). + */ + private function diffNative(string $old, string $new): array + { + $oldLines = explode("\n", $old); + $newLines = explode("\n", $new); + $result = []; + + // LCS-based diff + $lcs = $this->lcs($oldLines, $newLines); + $i = 0; $j = 0; $k = 0; + + while ($i < count($oldLines) || $j < count($newLines)) { + if ($k < count($lcs) && $i < count($oldLines) && $oldLines[$i] === $lcs[$k] && $j < count($newLines) && $newLines[$j] === $lcs[$k]) { + $result[] = ['type' => 'context', 'content' => $oldLines[$i], 'line_old' => $i + 1, 'line_new' => $j + 1]; + $i++; $j++; $k++; + } elseif ($j < count($newLines) && ($k >= count($lcs) || $newLines[$j] !== $lcs[$k])) { + $result[] = ['type' => 'added', 'content' => $newLines[$j], 'line_new' => $j + 1]; + $j++; + } else { + $result[] = ['type' => 'removed', 'content' => $oldLines[$i], 'line_old' => $i + 1]; + $i++; + } + } + return $result; + } + + private function parseUnifiedDiff(string $diff): array + { + $lines = explode("\n", $diff); + $result = []; + $oldLine = 0; $newLine = 0; + + foreach ($lines as $line) { + if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { + $result[] = ['type' => 'header', 'content' => $line]; + } elseif (str_starts_with($line, '@@')) { + // Parse @@ -old +new @@ + preg_match('/@@ -(\d+).*\+(\d+)/', $line, $m); + $oldLine = (int) ($m[1] ?? 0); + $newLine = (int) ($m[2] ?? 0); + $result[] = ['type' => 'header', 'content' => $line]; + } elseif (str_starts_with($line, '-')) { + $result[] = ['type' => 'removed', 'content' => substr($line, 1), 'line_old' => $oldLine++]; + } elseif (str_starts_with($line, '+')) { + $result[] = ['type' => 'added', 'content' => substr($line, 1), 'line_new' => $newLine++]; + } elseif (str_starts_with($line, ' ')) { + $result[] = ['type' => 'context', 'content' => substr($line, 1), 'line_old' => $oldLine++, 'line_new' => $newLine++]; + } + } + return $result; + } + + /** + * Longest Common Subsequence (for native diff). + */ + private function lcs(array $a, array $b): array + { + $m = count($a); $n = count($b); + $dp = array_fill(0, $m + 1, array_fill(0, $n + 1, 0)); + for ($i = 1; $i <= $m; $i++) { + for ($j = 1; $j <= $n; $j++) { + $dp[$i][$j] = $a[$i-1] === $b[$j-1] + ? $dp[$i-1][$j-1] + 1 + : max($dp[$i-1][$j], $dp[$i][$j-1]); + } + } + $result = []; $i = $m; $j = $n; + while ($i > 0 && $j > 0) { + if ($a[$i-1] === $b[$j-1]) { array_unshift($result, $a[$i-1]); $i--; $j--; } + elseif ($dp[$i-1][$j] > $dp[$i][$j-1]) { $i--; } + else { $j--; } + } + return $result; + } +} diff --git a/app/Addons/AdvancedAdmin/Services/Revisions/ExclusionMatcher.php b/app/Addons/AdvancedAdmin/Services/Revisions/ExclusionMatcher.php new file mode 100644 index 0000000..8a7ba9d --- /dev/null +++ b/app/Addons/AdvancedAdmin/Services/Revisions/ExclusionMatcher.php @@ -0,0 +1,47 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions; + +class ExclusionMatcher +{ + /** @var string[] */ + private array $patterns; + + public function __construct() + { + $this->patterns = config('advanced-admin.revisions.excluded_patterns', []); + } + + /** + * Returns true if the file should be excluded from tracking. + */ + public function isExcluded(string $filePath): bool + { + $basename = basename($filePath); + + foreach ($this->patterns as $pattern) { + // Match against both full path and basename + if (fnmatch($pattern, $basename, FNM_CASEFOLD)) { + return true; + } + if (fnmatch($pattern, $filePath, FNM_CASEFOLD | FNM_PATHNAME)) { + return true; + } + // Also check for case-insensitive substring matches on sensitive keywords + if (str_contains(strtolower($basename), 'secret') || + str_contains(strtolower($basename), 'password') || + str_contains(strtolower($basename), 'token') && str_ends_with($basename, '.json') + ) { + return true; + } + } + return false; + } +} diff --git a/app/Addons/AdvancedAdmin/Services/Revisions/RevisionService.php b/app/Addons/AdvancedAdmin/Services/Revisions/RevisionService.php new file mode 100644 index 0000000..9a21e3d --- /dev/null +++ b/app/Addons/AdvancedAdmin/Services/Revisions/RevisionService.php @@ -0,0 +1,166 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions; + +use Illuminate\Support\Facades\DB; +use Pterodactyl\Addons\AdvancedAdmin\Models\FileRevision; +use Pterodactyl\Addons\AdvancedAdmin\Models\RoleAuditLog; +use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionStorageService; +use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\ExclusionMatcher; + +class RevisionService +{ + public function __construct( + private readonly RevisionStorageService $storage, + private readonly ExclusionMatcher $exclusions, + ) {} + + /** + * Record a new revision for a file. + * Returns null if the file should be skipped (excluded, too large, binary unchanged, duplicate). + */ + public function record( + int $serverId, + string $filePath, + string $content, + ?int $authorId = null, + string $summary = '' + ): ?FileRevision { + // Security: sanitize path + $filePath = $this->storage->guardFilePath($filePath); + + // Skip excluded files + if ($this->exclusions->isExcluded($filePath)) { + return null; + } + + // Skip files above size limit + $maxSize = config('advanced-admin.revisions.max_file_size', 10 * 1024 * 1024); + if (strlen($content) > $maxSize) { + return null; + } + + $pathHash = $this->storage->pathHash($serverId, $filePath); + $contentHash = $this->storage->contentHash($content); + + // Deduplicate: if latest revision has same content, skip + $latest = FileRevision::query() + ->where('server_id', $serverId) + ->where('path_hash', $pathHash) + ->orderByDesc('revision_number') + ->first(); + + if ($latest && $latest->content_hash === $contentHash) { + return null; // No change + } + + $revisionNumber = ($latest?->revision_number ?? 0) + 1; + + return DB::transaction(function () use ( + $serverId, $filePath, $pathHash, $contentHash, + $revisionNumber, $content, $authorId, $summary + ) { + $stored = $this->storage->store($serverId, $pathHash, $revisionNumber, $content); + + $revision = FileRevision::create([ + 'server_id' => $serverId, + 'file_path' => $filePath, + 'path_hash' => $pathHash, + 'content_hash' => $contentHash, + 'revision_number' => $revisionNumber, + 'storage_key' => $stored['storage_key'], + 'storage_type' => 'full', + 'size_bytes' => $stored['size_bytes'], + 'compressed_size' => $stored['compressed_size'], + 'author_id' => $authorId, + 'change_summary' => $summary ?: 'File edited via panel', + ]); + + $this->updateStorageUsage($serverId, $stored['compressed_size']); + $this->enforceRetentionLimits($serverId, $pathHash); + + RoleAuditLog::record($authorId, 'revision.created', 'file_revision', $revision->id, [ + 'server_id' => $serverId, + 'file_path' => $filePath, + 'revision' => $revisionNumber, + ]); + + return $revision; + }); + } + + /** + * Restore a revision β€” returns the stored content. + */ + public function restore(FileRevision $revision, ?int $actorId = null): string + { + $content = $this->storage->retrieve($revision->storage_key); + + RoleAuditLog::record($actorId, 'revision.restored', 'file_revision', $revision->id, [ + 'server_id' => $revision->server_id, + 'file_path' => $revision->file_path, + 'revision' => $revision->revision_number, + ]); + + return $content; + } + + /** + * Delete a specific revision from storage and DB. + */ + public function delete(FileRevision $revision): void + { + DB::transaction(function () use ($revision) { + $this->storage->delete($revision->storage_key); + $this->updateStorageUsage($revision->server_id, -$revision->compressed_size); + $revision->delete(); + }); + } + + /** + * Enforce per-file and per-server retention limits. + * Deletes oldest revisions beyond the cap. + */ + public function enforceRetentionLimits(int $serverId, string $pathHash): void + { + $maxPerFile = config('advanced-admin.revisions.max_revisions_per_file', 50); + + $revisions = FileRevision::query() + ->where('server_id', $serverId) + ->where('path_hash', $pathHash) + ->orderByDesc('revision_number') + ->get(); + + $toDelete = $revisions->slice($maxPerFile); + foreach ($toDelete as $old) { + $this->delete($old); + } + + // Per-server storage cap + $maxStorage = config('advanced-admin.revisions.max_storage_per_server', 500 * 1024 * 1024); + $usage = \Pterodactyl\Addons\AdvancedAdmin\Models\FileRevisionStorageUsage::find($serverId); + if ($usage && $usage->total_bytes > $maxStorage) { + // Delete oldest revisions across all files until under cap + $oldest = FileRevision::query() + ->where('server_id', $serverId) + ->orderBy('created_at') + ->first(); + if ($oldest) $this->delete($oldest); + } + } + + private function updateStorageUsage(int $serverId, int $delta): void + { + \Pterodactyl\Addons\AdvancedAdmin\Models\FileRevisionStorageUsage::updateOrCreate( + ['server_id' => $serverId], + ['total_bytes' => DB::raw("total_bytes + {$delta}")] + ); + } +} diff --git a/app/Addons/AdvancedAdmin/Services/Revisions/RevisionStorageService.php b/app/Addons/AdvancedAdmin/Services/Revisions/RevisionStorageService.php new file mode 100644 index 0000000..a7733f6 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Services/Revisions/RevisionStorageService.php @@ -0,0 +1,134 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions; + +use Illuminate\Support\Facades\Storage; +use RuntimeException; + +class RevisionStorageService +{ + private string $disk = 'local'; + + /** + * Store file content, returning the storage key. + * Content is gzip-compressed before storage. + */ + public function store(int $serverId, string $pathHash, int $revisionNumber, string $content): array + { + $key = $this->buildKey($serverId, $pathHash, $revisionNumber); + $compressed = gzencode($content, 6); + if ($compressed === false) { + throw new RuntimeException('Failed to compress revision content.'); + } + Storage::disk($this->disk)->put($key, $compressed); + return [ + 'storage_key' => $key, + 'size_bytes' => strlen($content), + 'compressed_size' => strlen($compressed), + ]; + } + + /** + * Retrieve and decompress stored content. + */ + public function retrieve(string $storageKey): string + { + $this->guardKey($storageKey); + + $compressed = Storage::disk($this->disk)->get($storageKey); + if ($compressed === null) { + throw new RuntimeException("Revision file not found: {$storageKey}"); + } + $content = gzdecode($compressed); + if ($content === false) { + throw new RuntimeException("Failed to decompress revision: {$storageKey}"); + } + return $content; + } + + /** + * Delete a stored revision file. + */ + public function delete(string $storageKey): void + { + $this->guardKey($storageKey); + Storage::disk($this->disk)->delete($storageKey); + } + + /** + * Get the raw compressed file for streaming download. + */ + public function readStream(string $storageKey) + { + $this->guardKey($storageKey); + return Storage::disk($this->disk)->readStream($storageKey); + } + + /** + * Detect if content is binary (non-diffable). + */ + public function isBinary(string $content): bool + { + // Check for null bytes β€” reliable binary indicator + return str_contains($content, "\0"); + } + + /** + * Compute SHA-256 of content. + */ + public function contentHash(string $content): string + { + return hash('sha256', $content); + } + + /** + * Compute path hash (server-scoped, prevents cross-server lookup). + */ + public function pathHash(int $serverId, string $filePath): string + { + return hash('sha256', $serverId . ':' . $filePath); + } + + /** + * Validate a file path is within the server's allowed root. + * Prevents path traversal attacks. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function guardFilePath(string $filePath): string + { + // Normalize β€” reject absolute paths or traversal sequences + $normalized = str_replace('\\', '/', $filePath); + if (str_starts_with($normalized, '/') || str_contains($normalized, '../') || str_contains($normalized, './')) { + abort(422, 'Invalid file path.'); + } + return $normalized; + } + + // ─── Internal ───────────────────────────────────────────────────────────── + + private function buildKey(int $serverId, string $pathHash, int $revisionNumber): string + { + $base = config('advanced-admin.revisions.storage_path', 'addons/revisions'); + return "{$base}/{$serverId}/{$pathHash}/{$revisionNumber}.gz"; + } + + /** + * Prevent storage key manipulation β€” only allow keys within our storage prefix. + */ + private function guardKey(string $storageKey): void + { + $base = config('advanced-admin.revisions.storage_path', 'addons/revisions'); + if (!str_starts_with($storageKey, $base . '/')) { + abort(403, 'Invalid storage key.'); + } + } +} diff --git a/app/Addons/AdvancedAdmin/Services/Roles/PermissionResolver.php b/app/Addons/AdvancedAdmin/Services/Roles/PermissionResolver.php new file mode 100644 index 0000000..8361ef3 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Services/Roles/PermissionResolver.php @@ -0,0 +1,123 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Services\Roles; + +use Illuminate\Support\Collection; +use Pterodactyl\Addons\AdvancedAdmin\Models\Role; +use Pterodactyl\Addons\AdvancedAdmin\Models\RoleAuditLog; +use Pterodactyl\Addons\AdvancedAdmin\Models\ServerPermissionOverride; +use Pterodactyl\Addons\AdvancedAdmin\Models\ServerRoleAssignment; + +class PermissionResolver +{ + /** + * Resolve whether a user has a permission on a server. + * + * @return bool + */ + public function resolve(int $userId, int $serverId, string $permission): bool + { + // 1 & 2: Check server-level override + $override = ServerPermissionOverride::query() + ->where('server_id', $serverId) + ->where('user_id', $userId) + ->where('permission', $permission) + ->first(); + + if ($override && !$override->isUnset()) { + return $override->isAllow(); + } + + // 3 & 4: Check role-level (sorted by priority desc β€” highest wins) + $roles = $this->getUserRolesForServer($userId, $serverId); + + foreach ($roles as $role) { + $value = $role->getPermission($permission); + if ($value === 'deny') return false; + if ($value === 'allow') return true; + } + + // 5: Default deny + return false; + } + + /** + * Get full effective permission map for a user on a server. + * Returns array of [permission => ['value' => bool, 'source' => string]] + * + * @param string[] $allPermissions + * @return array + */ + public function resolveAll(int $userId, int $serverId, array $allPermissions): array + { + $overrides = ServerPermissionOverride::query() + ->where('server_id', $serverId) + ->where('user_id', $userId) + ->get() + ->keyBy('permission'); + + $roles = $this->getUserRolesForServer($userId, $serverId); + + $result = []; + foreach ($allPermissions as $permission) { + [$value, $source] = $this->resolveWithSource($permission, $overrides, $roles); + $result[$permission] = ['value' => $value, 'source' => $source]; + } + return $result; + } + + /** + * Get user's assigned roles for a server, ordered by priority DESC. + */ + public function getUserRolesForServer(int $userId, int $serverId): Collection + { + return ServerRoleAssignment::query() + ->where('server_id', $serverId) + ->where('user_id', $userId) + ->with(['role.permissions']) + ->get() + ->map(fn ($a) => $a->role) + ->filter() + ->sortByDesc('priority') + ->values(); + } + + // ─── Internal ───────────────────────────────────────────────────────────── + + private function resolveWithSource( + string $permission, + Collection $overrides, + Collection $roles + ): array { + // Server override + if ($overrides->has($permission)) { + $o = $overrides->get($permission); + if (!$o->isUnset()) { + return [$o->isAllow(), 'server_override']; + } + } + + // Role + foreach ($roles as $role) { + $value = $role->getPermission($permission); + if ($value === 'deny') return [false, "role:{$role->name}"]; + if ($value === 'allow') return [true, "role:{$role->name}"]; + } + + return [false, 'default']; + } +} diff --git a/app/Addons/AdvancedAdmin/Services/Roles/RoleService.php b/app/Addons/AdvancedAdmin/Services/Roles/RoleService.php new file mode 100644 index 0000000..6290690 --- /dev/null +++ b/app/Addons/AdvancedAdmin/Services/Roles/RoleService.php @@ -0,0 +1,201 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Services\Roles; + +use Illuminate\Validation\ValidationException; +use Pterodactyl\Addons\AdvancedAdmin\Models\Role; +use Pterodactyl\Addons\AdvancedAdmin\Models\RoleAuditLog; +use Pterodactyl\Addons\AdvancedAdmin\Models\RolePermission; +use Pterodactyl\Addons\AdvancedAdmin\Models\ServerPermissionOverride; +use Pterodactyl\Addons\AdvancedAdmin\Models\ServerRoleAssignment; +use Illuminate\Support\Facades\DB; + +class RoleService +{ + /** + * All known Pterodactyl permission keys (validated against). + */ + public const KNOWN_PERMISSIONS = [ + 'websocket.connect', + 'control.console', 'control.start', 'control.stop', 'control.restart', + 'user.read', 'user.create', 'user.update', 'user.delete', + 'file.read', 'file.read-content', 'file.create', 'file.update', + 'file.delete', 'file.archive', 'file.sftp', + 'backup.read', 'backup.create', 'backup.delete', 'backup.download', 'backup.restore', + 'allocation.read', 'allocation.create', 'allocation.update', 'allocation.delete', + 'startup.read', 'startup.update', 'startup.docker-image', + 'database.read', 'database.create', 'database.update', 'database.delete', 'database.view-password', + 'schedule.read', 'schedule.create', 'schedule.update', 'schedule.delete', + 'settings.read', 'settings.rename', 'settings.reinstall', + 'activity.read', + ]; + + public function create(array $data, int $actorId): Role + { + return DB::transaction(function () use ($data, $actorId) { + $role = Role::create([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'color' => $data['color'] ?? '#6366f1', + 'priority' => $data['priority'] ?? 0, + 'is_default' => $data['is_default'] ?? false, + 'created_by' => $actorId, + ]); + + if (!empty($data['permissions'])) { + $this->syncPermissions($role, $data['permissions'], $actorId); + } + + RoleAuditLog::record($actorId, 'role.create', 'role', $role->id, ['name' => $role->name]); + return $role; + }); + } + + public function update(Role $role, array $data, int $actorId): Role + { + return DB::transaction(function () use ($role, $data, $actorId) { + $role->update(array_filter([ + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'color' => $data['color'] ?? null, + 'priority' => $data['priority'] ?? null, + 'is_default' => $data['is_default'] ?? null, + ], fn ($v) => $v !== null)); + + if (isset($data['permissions'])) { + $this->syncPermissions($role, $data['permissions'], $actorId); + } + + RoleAuditLog::record($actorId, 'role.update', 'role', $role->id, $data); + return $role->refresh(); + }); + } + + public function delete(Role $role, int $actorId, bool $force = false): void + { + if ($role->isInUse() && !$force) { + throw ValidationException::withMessages([ + 'role' => ['This role is still assigned to users. Pass force=true to delete anyway.'], + ]); + } + + DB::transaction(function () use ($role, $actorId) { + RoleAuditLog::record($actorId, 'role.delete', 'role', $role->id, ['name' => $role->name]); + $role->delete(); + }); + } + + public function syncPermissions(Role $role, array $permissions, int $actorId): void + { + // Validate all permission names + $invalid = array_diff(array_keys($permissions), self::KNOWN_PERMISSIONS); + if (!empty($invalid)) { + throw ValidationException::withMessages([ + 'permissions' => ['Unknown permissions: ' . implode(', ', $invalid)], + ]); + } + + DB::transaction(function () use ($role, $permissions, $actorId) { + $role->permissions()->delete(); + foreach ($permissions as $perm => $value) { + if (!in_array($value, ['allow', 'deny'], true)) continue; + RolePermission::create([ + 'role_id' => $role->id, + 'permission' => $perm, + 'value' => $value, + ]); + } + RoleAuditLog::record($actorId, 'role.permissions_updated', 'role', $role->id, ['permissions' => $permissions]); + }); + } + + public function assignToUser(int $serverId, int $userId, int $roleId, int $actorId): ServerRoleAssignment + { + // Prevent privilege escalation: actor's max role priority must be >= target role priority + $targetRole = Role::findOrFail($roleId); + $actorMaxPrio = $this->getMaxPriorityForUser($actorId, $serverId); + + if ($targetRole->priority > $actorMaxPrio) { + throw ValidationException::withMessages([ + 'role' => ['You cannot assign a role with higher priority than your own.'], + ]); + } + + $assignment = ServerRoleAssignment::firstOrCreate([ + 'server_id' => $serverId, + 'user_id' => $userId, + 'role_id' => $roleId, + ], [ + 'assigned_by' => $actorId, + ]); + + RoleAuditLog::record($actorId, 'role.assign', 'user', $userId, [ + 'server_id' => $serverId, + 'role_id' => $roleId, + 'role_name' => $targetRole->name, + ]); + + return $assignment; + } + + public function unassignFromUser(int $serverId, int $userId, int $roleId, int $actorId): void + { + ServerRoleAssignment::query() + ->where('server_id', $serverId) + ->where('user_id', $userId) + ->where('role_id', $roleId) + ->delete(); + + RoleAuditLog::record($actorId, 'role.unassign', 'user', $userId, [ + 'server_id' => $serverId, + 'role_id' => $roleId, + ]); + } + + public function setOverride( + int $serverId, + int $userId, + string $permission, + string $value, + int $actorId + ): ServerPermissionOverride { + if (!in_array($permission, self::KNOWN_PERMISSIONS, true)) { + throw ValidationException::withMessages(['permission' => ['Unknown permission key.']]); + } + if (!in_array($value, ['allow', 'deny', 'unset'], true)) { + throw ValidationException::withMessages(['value' => ['Value must be allow, deny, or unset.']]); + } + + $override = ServerPermissionOverride::updateOrCreate( + ['server_id' => $serverId, 'user_id' => $userId, 'permission' => $permission], + ['value' => $value, 'set_by' => $actorId] + ); + + RoleAuditLog::record($actorId, 'override.set', 'user', $userId, [ + 'server_id' => $serverId, + 'permission' => $permission, + 'value' => $value, + ]); + + return $override; + } + + private function getMaxPriorityForUser(int $userId, int $serverId): int + { + // If admin, return PHP_INT_MAX + $user = \Pterodactyl\Models\User::find($userId); + if ($user && $user->root_admin) return PHP_INT_MAX; + + return ServerRoleAssignment::query() + ->where('server_id', $serverId) + ->where('user_id', $userId) + ->with('role') + ->get() + ->max(fn ($a) => $a->role->priority ?? 0) ?? 0; + } +} diff --git a/config/addon-commands/vanilla.json b/config/addon-commands/vanilla.json new file mode 100644 index 0000000..0ff0dc2 --- /dev/null +++ b/config/addon-commands/vanilla.json @@ -0,0 +1,210 @@ +[ + { + "name": "say", + "aliases": [], + "description": "Broadcasts a message to all players", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "message", "type": "string", "required": true, "suggestions": [] } + ], + "examples": ["say Hello everyone!"], + "permission": "minecraft.command.say" + }, + { + "name": "ban", + "aliases": [], + "description": "Bans a player from the server", + "plugin": "vanilla", + "dangerous": true, + "dangerReason": "Permanently bans a player", + "args": [ + { "name": "target", "type": "player", "required": true, "suggestions": [] }, + { "name": "reason", "type": "string", "required": false, "suggestions": [] } + ], + "examples": ["ban Steve Cheating", "ban Notch Griefing"], + "permission": "minecraft.command.ban" + }, + { + "name": "op", + "aliases": [], + "description": "Gives operator status to a player", + "plugin": "vanilla", + "dangerous": true, + "dangerReason": "Grants full server operator access", + "args": [ + { "name": "target", "type": "player", "required": true, "suggestions": [] } + ], + "examples": ["op Steve"], + "permission": "minecraft.command.op" + }, + { + "name": "deop", + "aliases": [], + "description": "Revokes operator status from a player", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "target", "type": "player", "required": true, "suggestions": [] } + ], + "examples": ["deop Steve"], + "permission": "minecraft.command.deop" + }, + { + "name": "gamemode", + "aliases": ["gm"], + "description": "Changes a player's game mode", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "mode", "type": "string", "required": true, "suggestions": ["survival", "creative", "adventure", "spectator", "0", "1", "2", "3"] }, + { "name": "target", "type": "player", "required": false, "suggestions": [] } + ], + "examples": ["gamemode creative", "gamemode survival Steve"], + "permission": "minecraft.command.gamemode" + }, + { + "name": "give", + "aliases": [], + "description": "Gives an item to a player", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "target", "type": "player", "required": true, "suggestions": [] }, + { "name": "item", "type": "string", "required": true, "suggestions": ["minecraft:diamond", "minecraft:iron_sword", "minecraft:netherite_pickaxe"] }, + { "name": "count", "type": "number", "required": false, "suggestions": ["1", "16", "64"] } + ], + "examples": ["give Steve minecraft:diamond 64"], + "permission": "minecraft.command.give" + }, + { + "name": "tp", + "aliases": ["teleport"], + "description": "Teleports entities", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "target", "type": "player", "required": true, "suggestions": [] }, + { "name": "destination", "type": "player", "required": false, "suggestions": [] } + ], + "examples": ["tp Steve Alex", "tp Steve 100 64 200"], + "permission": "minecraft.command.tp" + }, + { + "name": "whitelist", + "aliases": [], + "description": "Manages the server whitelist", + "plugin": "vanilla", + "dangerous": true, + "dangerReason": "whitelist off allows anyone to join", + "args": [ + { "name": "action", "type": "string", "required": true, "suggestions": ["on", "off", "add", "remove", "list", "reload"] }, + { "name": "player", "type": "player", "required": false, "suggestions": [] } + ], + "examples": ["whitelist add Steve", "whitelist on"], + "permission": "minecraft.command.whitelist" + }, + { + "name": "stop", + "aliases": [], + "description": "Stops the server", + "plugin": "vanilla", + "dangerous": true, + "dangerReason": "Stops the server immediately", + "args": [], + "examples": ["stop"], + "permission": "minecraft.command.stop" + }, + { + "name": "kill", + "aliases": [], + "description": "Kills entities", + "plugin": "vanilla", + "dangerous": true, + "dangerReason": "kill @e kills all entities; kill @a kills all players", + "args": [ + { "name": "target", "type": "string", "required": true, "suggestions": ["@e", "@a", "@r", "@p", "@s"] } + ], + "examples": ["kill @e[type=zombie]", "kill Steve"], + "permission": "minecraft.command.kill" + }, + { + "name": "time", + "aliases": [], + "description": "Queries or changes the world time", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "action", "type": "string", "required": true, "suggestions": ["set", "add", "query"] }, + { "name": "value", "type": "string", "required": false, "suggestions": ["day", "night", "noon", "midnight", "0", "6000", "12000", "18000"] } + ], + "examples": ["time set day", "time set 6000"], + "permission": "minecraft.command.time" + }, + { + "name": "weather", + "aliases": [], + "description": "Sets the weather", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "type", "type": "string", "required": true, "suggestions": ["clear", "rain", "thunder"] }, + { "name": "duration", "type": "number", "required": false, "suggestions": ["60", "300", "600"] } + ], + "examples": ["weather clear", "weather thunder 300"], + "permission": "minecraft.command.weather" + }, + { + "name": "difficulty", + "aliases": [], + "description": "Sets the difficulty level", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "difficulty", "type": "string", "required": true, "suggestions": ["peaceful", "easy", "normal", "hard"] } + ], + "examples": ["difficulty hard"], + "permission": "minecraft.command.difficulty" + }, + { + "name": "effect", + "aliases": [], + "description": "Gives or removes status effects", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "action", "type": "string", "required": true, "suggestions": ["give", "clear"] }, + { "name": "target", "type": "player", "required": true, "suggestions": [] }, + { "name": "effect", "type": "string", "required": false, "suggestions": ["speed", "jump_boost", "regeneration", "strength", "night_vision", "fire_resistance", "water_breathing", "invisibility"] } + ], + "examples": ["effect give Steve speed 30 2"], + "permission": "minecraft.command.effect" + }, + { + "name": "enchant", + "aliases": [], + "description": "Enchants a player's held item", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "target", "type": "player", "required": true, "suggestions": [] }, + { "name": "enchantment", "type": "string", "required": true, "suggestions": ["sharpness", "protection", "efficiency", "unbreaking", "fortune", "silk_touch", "mending"] }, + { "name": "level", "type": "number", "required": false, "suggestions": ["1", "2", "3", "4", "5"] } + ], + "examples": ["enchant Steve sharpness 5"], + "permission": "minecraft.command.enchant" + }, + { + "name": "gamerule", + "aliases": [], + "description": "Sets or queries a game rule", + "plugin": "vanilla", + "dangerous": false, + "args": [ + { "name": "rule", "type": "string", "required": true, "suggestions": ["doDaylightCycle", "doFireTick", "doMobSpawning", "keepInventory", "pvp", "doWeatherCycle", "commandBlockOutput", "naturalRegeneration"] }, + { "name": "value", "type": "string", "required": false, "suggestions": ["true", "false"] } + ], + "examples": ["gamerule keepInventory true"], + "permission": "minecraft.command.gamerule" + } +] diff --git a/config/advanced-admin.php b/config/advanced-admin.php new file mode 100644 index 0000000..f3c9d6d --- /dev/null +++ b/config/advanced-admin.php @@ -0,0 +1,192 @@ + + * @license MIT + */ + +return [ + + /* + |-------------------------------------------------------------------------- + | Addon Meta + |-------------------------------------------------------------------------- + */ + 'version' => '1.0.0', + 'author' => 'Ball Studios', + 'repo' => 'https://git.balls.studio/BallStudios/pterodactyl_addon', + + /* + |-------------------------------------------------------------------------- + | Network Traffic Dashboard + |-------------------------------------------------------------------------- + */ + 'network' => [ + // Enable/disable the module entirely + 'enabled' => env('ADDON_NETWORK_ENABLED', true), + + // How often (seconds) to collect Docker stats metrics + 'collection_interval_seconds' => env('ADDON_NETWORK_INTERVAL', 10), + + // Enable iptables/nftables port-level collection (requires host access) + 'iptables_enabled' => env('ADDON_NETWORK_IPTABLES', false), + + // Retention periods (days) per resolution tier + 'retention' => [ + 'raw' => env('ADDON_NETWORK_RETENTION_RAW', 1), // 1 day raw + '5m' => env('ADDON_NETWORK_RETENTION_5M', 7), // 7 days 5-min + '1h' => env('ADDON_NETWORK_RETENTION_1H', 30), // 30 days hourly + '1d' => env('ADDON_NETWORK_RETENTION_1D', 365), // 365 days daily + ], + + // Max live-poll rate (requests per minute per user per server) + 'rate_limit_per_minute' => env('ADDON_NETWORK_RATE_LIMIT', 12), + + // Wings REST stats endpoint path (relative to node FQDN:port) + 'wings_stats_path' => '/api/servers/{uuid}/resources', + ], + + /* + |-------------------------------------------------------------------------- + | Role-Based Server Permissions + |-------------------------------------------------------------------------- + */ + 'roles' => [ + 'enabled' => env('ADDON_ROLES_ENABLED', true), + + // Maximum roles per server (0 = unlimited) + 'max_roles_per_server' => env('ADDON_ROLES_MAX', 0), + + // Warn before deleting a role that has active assignments + 'warn_on_delete_in_use' => true, + + // Known dangerous permissions to flag in UI + 'dangerous_permissions' => [ + 'file.delete', + 'allocation.*', + 'startup.*', + 'database.*', + 'backup.delete', + 'user.update', + 'control.console', + ], + + // Rate limit for role write operations (per minute per admin) + 'write_rate_limit' => env('ADDON_ROLES_WRITE_RATE_LIMIT', 30), + ], + + /* + |-------------------------------------------------------------------------- + | File Revision History + |-------------------------------------------------------------------------- + */ + 'revisions' => [ + 'enabled' => env('ADDON_REVISIONS_ENABLED', true), + + // Where to store revision files (relative to storage/app/) + 'storage_path' => env('ADDON_REVISIONS_PATH', 'addons/revisions'), + + // Max file size to track (bytes). Files larger are skipped. + 'max_file_size' => env('ADDON_REVISIONS_MAX_FILE_SIZE', 10 * 1024 * 1024), // 10 MB + + // Max revisions kept per file + 'max_revisions_per_file' => env('ADDON_REVISIONS_MAX_PER_FILE', 50), + + // Max total storage per server (bytes) + 'max_storage_per_server' => env('ADDON_REVISIONS_MAX_STORAGE', 500 * 1024 * 1024), // 500 MB + + // Revision retention in days (0 = keep forever up to max_revisions_per_file) + 'retention_days' => env('ADDON_REVISIONS_RETENTION_DAYS', 90), + + // Glob patterns to EXCLUDE from tracking (never store these) + 'excluded_patterns' => [ + '.env', + '*.env', + '*.key', + '*.pem', + '*.p12', + '*.pfx', + '*.crt', + '*.cer', + '*.jks', + 'secrets.yml', + 'secrets.yaml', + '*secret*', + '*password*', + '*token*', + '*.sql', + '*.sql.gz', + '*.bak', + '*.backup', + '*.dump', + '*.zip', + '*.tar', + '*.tar.gz', + '*.tar.bz2', + '*.7z', + '*.rar', + ], + + // Use xdiff PHP extension for delta storage if available + 'prefer_delta_storage' => env('ADDON_REVISIONS_DELTA', true), + + // Rate limit for restore operations (per minute per user) + 'restore_rate_limit' => env('ADDON_REVISIONS_RESTORE_RATE_LIMIT', 10), + ], + + /* + |-------------------------------------------------------------------------- + | Console Command Autocomplete + |-------------------------------------------------------------------------- + */ + 'console' => [ + 'enabled' => env('ADDON_CONSOLE_ENABLED', true), + + // Path to bundled command definition JSON files + 'definitions_path' => base_path('config/addon-commands'), + + // Debounce (ms) applied on frontend β€” informational only + 'frontend_debounce_ms' => 150, + + // Rate limit for autocomplete endpoint (per minute per user per server) + 'autocomplete_rate_limit' => env('ADDON_CONSOLE_RATE_LIMIT', 60), + + // Stale player cache expiry (minutes) + 'player_cache_ttl_minutes' => env('ADDON_CONSOLE_PLAYER_TTL', 30), + + // Max suggestions returned per query + 'max_suggestions' => env('ADDON_CONSOLE_MAX_SUGGESTIONS', 10), + + // Console output patterns for join/leave detection per software type + 'patterns' => [ + 'join' => [ + 'paper' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) joined the game/', + 'spigot' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) joined the game/', + 'purpur' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) joined the game/', + 'fabric' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) joined the game/', + 'forge' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) joined the game/', + 'velocity' => '/\[[\d:]+\] \[INFO\]: (\w[\w\d_]{0,15}) \[.+\] has connected/', + 'bungeecord' => '/\[[\d:]+\] \[INFO\]: (\w[\w\d_]{0,15}) \[.+\] connected/', + ], + 'leave' => [ + 'paper' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) left the game/', + 'spigot' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) left the game/', + 'purpur' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) left the game/', + 'fabric' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) left the game/', + 'forge' => '/\[[\d:]+\] \[Server thread\/INFO\]: (\w+) left the game/', + 'velocity' => '/\[[\d:]+\] \[INFO\]: (\w[\w\d_]{0,15}) \[.+\] has disconnected/', + 'bungeecord' => '/\[[\d:]+\] \[INFO\]: (\w[\w\d_]{0,15}) \[.+\] disconnected/', + ], + ], + + // Commands that trigger a danger warning in the UI + 'dangerous_commands' => [ + 'stop', 'restart', 'ban', 'ban-ip', 'pardon', 'pardon-ip', + 'op', 'deop', 'whitelist off', 'save-off', 'kill @e', 'kill @a', + 'forceload', 'execute', 'function', + ], + ], + +]; diff --git a/database/migrations/addon/2024_01_01_000001_create_addon_network_metrics_tables.php b/database/migrations/addon/2024_01_01_000001_create_addon_network_metrics_tables.php new file mode 100644 index 0000000..69e57d3 --- /dev/null +++ b/database/migrations/addon/2024_01_01_000001_create_addon_network_metrics_tables.php @@ -0,0 +1,86 @@ + + * @license MIT + */ + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('addon_network_metrics', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedInteger('server_id')->index(); + $table->unsignedInteger('node_id')->index(); + $table->unsignedBigInteger('rx_bytes')->default(0); + $table->unsignedBigInteger('tx_bytes')->default(0); + $table->enum('resolution', ['raw', '5m', '1h', '1d'])->default('raw'); + $table->timestamp('recorded_at'); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['server_id', 'resolution', 'recorded_at']); + $table->index(['node_id', 'recorded_at']); + + $table->foreign('server_id') + ->references('id')->on('servers') + ->onDelete('cascade'); + $table->foreign('node_id') + ->references('id')->on('nodes') + ->onDelete('cascade'); + }); + + Schema::create('addon_network_port_metrics', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedInteger('server_id')->index(); + $table->unsignedSmallInteger('port'); + $table->enum('protocol', ['tcp', 'udp'])->default('tcp'); + $table->unsignedBigInteger('rx_bytes')->default(0); + $table->unsignedBigInteger('tx_bytes')->default(0); + $table->enum('resolution', ['raw', '5m', '1h', '1d'])->default('raw'); + $table->timestamp('recorded_at'); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['server_id', 'port', 'recorded_at']); + + $table->foreign('server_id') + ->references('id')->on('servers') + ->onDelete('cascade'); + }); + + Schema::create('addon_network_flows', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedInteger('src_server_id')->nullable()->index(); + $table->unsignedInteger('dst_server_id')->nullable()->index(); + $table->string('src_ip', 45)->nullable(); + $table->string('dst_ip', 45)->nullable(); + $table->unsignedBigInteger('bytes')->default(0); + $table->unsignedBigInteger('packets')->default(0); + $table->timestamp('recorded_at'); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['src_server_id', 'recorded_at']); + $table->index(['dst_server_id', 'recorded_at']); + + $table->foreign('src_server_id') + ->references('id')->on('servers') + ->onDelete('set null'); + $table->foreign('dst_server_id') + ->references('id')->on('servers') + ->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::dropIfExists('addon_network_flows'); + Schema::dropIfExists('addon_network_port_metrics'); + Schema::dropIfExists('addon_network_metrics'); + } +}; diff --git a/database/migrations/addon/2024_01_01_000002_create_addon_roles_tables.php b/database/migrations/addon/2024_01_01_000002_create_addon_roles_tables.php new file mode 100644 index 0000000..8028618 --- /dev/null +++ b/database/migrations/addon/2024_01_01_000002_create_addon_roles_tables.php @@ -0,0 +1,120 @@ + + * @license MIT + */ + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('addon_roles', function (Blueprint $table) { + $table->increments('id'); + $table->string('name', 100); + $table->text('description')->nullable(); + $table->string('color', 7)->default('#6366f1'); // indigo + $table->unsignedInteger('priority')->default(0); + $table->boolean('is_default')->default(false); + $table->unsignedInteger('created_by')->nullable(); + $table->timestamps(); + + $table->foreign('created_by') + ->references('id')->on('users') + ->onDelete('set null'); + }); + + Schema::create('addon_role_permissions', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedInteger('role_id'); + $table->string('permission', 100); + $table->enum('value', ['allow', 'deny']); + $table->timestamps(); + + $table->unique(['role_id', 'permission']); + $table->index(['role_id', 'permission']); + + $table->foreign('role_id') + ->references('id')->on('addon_roles') + ->onDelete('cascade'); + }); + + Schema::create('addon_server_role_assignments', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedInteger('server_id'); + $table->unsignedInteger('user_id'); + $table->unsignedInteger('role_id'); + $table->unsignedInteger('assigned_by')->nullable(); + $table->timestamps(); + + $table->unique(['server_id', 'user_id', 'role_id']); + $table->index(['server_id', 'user_id']); + + $table->foreign('server_id') + ->references('id')->on('servers') + ->onDelete('cascade'); + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade'); + $table->foreign('role_id') + ->references('id')->on('addon_roles') + ->onDelete('cascade'); + $table->foreign('assigned_by') + ->references('id')->on('users') + ->onDelete('set null'); + }); + + Schema::create('addon_server_permission_overrides', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedInteger('server_id'); + $table->unsignedInteger('user_id'); + $table->string('permission', 100); + $table->enum('value', ['allow', 'deny', 'unset'])->default('unset'); + $table->unsignedInteger('set_by')->nullable(); + $table->timestamps(); + + $table->unique(['server_id', 'user_id', 'permission']); + $table->index(['server_id', 'user_id']); + + $table->foreign('server_id') + ->references('id')->on('servers') + ->onDelete('cascade'); + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade'); + }); + + Schema::create('addon_role_audit_log', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedInteger('actor_id')->nullable(); + $table->string('action', 80); + $table->string('target_type', 50)->nullable(); + $table->unsignedBigInteger('target_id')->nullable(); + $table->json('payload')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['actor_id', 'created_at']); + $table->index(['action', 'created_at']); + + $table->foreign('actor_id') + ->references('id')->on('users') + ->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::dropIfExists('addon_role_audit_log'); + Schema::dropIfExists('addon_server_permission_overrides'); + Schema::dropIfExists('addon_server_role_assignments'); + Schema::dropIfExists('addon_role_permissions'); + Schema::dropIfExists('addon_roles'); + } +}; diff --git a/database/migrations/addon/2024_01_01_000003_create_addon_file_revisions_tables.php b/database/migrations/addon/2024_01_01_000003_create_addon_file_revisions_tables.php new file mode 100644 index 0000000..a7cfc4d --- /dev/null +++ b/database/migrations/addon/2024_01_01_000003_create_addon_file_revisions_tables.php @@ -0,0 +1,61 @@ + + * @license MIT + */ + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('addon_file_revisions', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedInteger('server_id'); + $table->string('file_path', 500); + $table->char('path_hash', 64); // SHA-256(server_id.file_path) for lookup + $table->char('content_hash', 64); // SHA-256 of content (deduplication) + $table->unsignedInteger('revision_number')->default(1); + $table->string('storage_key', 255); // relative path under storage disk + $table->enum('storage_type', ['full', 'delta'])->default('full'); + $table->unsignedBigInteger('size_bytes')->default(0); + $table->unsignedBigInteger('compressed_size')->default(0); + $table->unsignedInteger('author_id')->nullable(); + $table->string('change_summary', 500)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['server_id', 'path_hash', 'revision_number']); + $table->index(['server_id', 'path_hash', 'created_at']); + $table->index('content_hash'); + + $table->foreign('server_id') + ->references('id')->on('servers') + ->onDelete('cascade'); + $table->foreign('author_id') + ->references('id')->on('users') + ->onDelete('set null'); + }); + + Schema::create('addon_file_revision_storage_usage', function (Blueprint $table) { + $table->unsignedInteger('server_id')->primary(); + $table->unsignedBigInteger('total_bytes')->default(0); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + + $table->foreign('server_id') + ->references('id')->on('servers') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('addon_file_revision_storage_usage'); + Schema::dropIfExists('addon_file_revisions'); + } +}; diff --git a/database/migrations/addon/2024_01_01_000004_create_addon_console_tables.php b/database/migrations/addon/2024_01_01_000004_create_addon_console_tables.php new file mode 100644 index 0000000..b817bd5 --- /dev/null +++ b/database/migrations/addon/2024_01_01_000004_create_addon_console_tables.php @@ -0,0 +1,56 @@ + + * @license MIT + */ + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('addon_command_definitions', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('server_id')->nullable()->index(); + $table->unsignedInteger('node_id')->nullable()->index(); + $table->unsignedInteger('egg_id')->nullable()->index(); + $table->string('plugin_source', 100)->default('custom'); + $table->string('command_name', 100)->index(); + $table->json('definition'); // full command schema + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->foreign('server_id') + ->references('id')->on('servers') + ->onDelete('cascade'); + $table->foreign('node_id') + ->references('id')->on('nodes') + ->onDelete('cascade'); + }); + + Schema::create('addon_console_player_cache', function (Blueprint $table) { + $table->unsignedInteger('server_id'); + $table->string('player_name', 64); + $table->timestamp('last_seen_at')->useCurrent()->useCurrentOnUpdate(); + + $table->primary(['server_id', 'player_name']); + $table->index(['server_id', 'last_seen_at']); + + $table->foreign('server_id') + ->references('id')->on('servers') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('addon_console_player_cache'); + Schema::dropIfExists('addon_command_definitions'); + } +}; diff --git a/database/seeders/DefaultRolesSeeder.php b/database/seeders/DefaultRolesSeeder.php new file mode 100644 index 0000000..8698ec9 --- /dev/null +++ b/database/seeders/DefaultRolesSeeder.php @@ -0,0 +1,109 @@ + + * @license MIT + */ + +namespace Pterodactyl\Addons\AdvancedAdmin\Database\Seeders; + +use Illuminate\Database\Seeder; +use Pterodactyl\Addons\AdvancedAdmin\Models\Role; +use Pterodactyl\Addons\AdvancedAdmin\Models\RolePermission; + +class DefaultRolesSeeder extends Seeder +{ + private const ROLES = [ + [ + 'name' => 'Owner', + 'description' => 'Full server access β€” assigned to the server owner', + 'color' => '#f59e0b', + 'priority' => 100, + 'is_default' => false, + 'permissions' => [ + 'control.console' => 'allow', 'control.start' => 'allow', 'control.stop' => 'allow', 'control.restart' => 'allow', + 'file.read' => 'allow', 'file.read-content' => 'allow', 'file.create' => 'allow', 'file.update' => 'allow', 'file.delete' => 'allow', 'file.archive' => 'allow', 'file.sftp' => 'allow', + 'backup.read' => 'allow', 'backup.create' => 'allow', 'backup.delete' => 'allow', 'backup.download' => 'allow', 'backup.restore' => 'allow', + 'allocation.read' => 'allow', 'allocation.create' => 'allow', 'allocation.update' => 'allow', 'allocation.delete' => 'allow', + 'startup.read' => 'allow', 'startup.update' => 'allow', 'startup.docker-image' => 'allow', + 'database.read' => 'allow', 'database.create' => 'allow', 'database.update' => 'allow', 'database.delete' => 'allow', 'database.view-password' => 'allow', + 'schedule.read' => 'allow', 'schedule.create' => 'allow', 'schedule.update' => 'allow', 'schedule.delete' => 'allow', + 'settings.read' => 'allow', 'settings.rename' => 'allow', 'settings.reinstall' => 'allow', + 'user.read' => 'allow', 'user.create' => 'allow', 'user.update' => 'allow', 'user.delete' => 'allow', + 'activity.read' => 'allow', 'websocket.connect' => 'allow', + ], + ], + [ + 'name' => 'Administrator', + 'description' => 'High-privilege access β€” can manage files, users, databases', + 'color' => '#ef4444', + 'priority' => 80, + 'is_default' => false, + 'permissions' => [ + 'control.console' => 'allow', 'control.start' => 'allow', 'control.stop' => 'allow', 'control.restart' => 'allow', + 'file.read' => 'allow', 'file.read-content' => 'allow', 'file.create' => 'allow', 'file.update' => 'allow', 'file.delete' => 'allow', 'file.archive' => 'allow', 'file.sftp' => 'allow', + 'backup.read' => 'allow', 'backup.create' => 'allow', 'backup.delete' => 'allow', 'backup.download' => 'allow', 'backup.restore' => 'allow', + 'database.read' => 'allow', 'database.create' => 'allow', 'database.update' => 'allow', 'database.delete' => 'allow', 'database.view-password' => 'allow', + 'schedule.read' => 'allow', 'schedule.create' => 'allow', 'schedule.update' => 'allow', 'schedule.delete' => 'allow', + 'settings.read' => 'allow', 'user.read' => 'allow', 'user.create' => 'allow', 'user.update' => 'allow', + 'activity.read' => 'allow', 'websocket.connect' => 'allow', + ], + ], + [ + 'name' => 'Moderator', + 'description' => 'Console access, read files, manage schedules', + 'color' => '#3b82f6', + 'priority' => 50, + 'is_default' => false, + 'permissions' => [ + 'control.console' => 'allow', 'control.start' => 'allow', 'control.stop' => 'allow', 'control.restart' => 'allow', + 'file.read' => 'allow', 'file.read-content' => 'allow', + 'backup.read' => 'allow', 'backup.create' => 'allow', + 'schedule.read' => 'allow', 'schedule.create' => 'allow', 'schedule.update' => 'allow', + 'settings.read' => 'allow', 'activity.read' => 'allow', 'websocket.connect' => 'allow', + ], + ], + [ + 'name' => 'Viewer', + 'description' => 'Read-only access β€” can view console and files', + 'color' => '#6b7280', + 'priority' => 10, + 'is_default' => true, + 'permissions' => [ + 'control.console' => 'allow', + 'file.read' => 'allow', 'file.read-content' => 'allow', + 'backup.read' => 'allow', + 'schedule.read' => 'allow', + 'settings.read' => 'allow', 'activity.read' => 'allow', 'websocket.connect' => 'allow', + ], + ], + ]; + + public function run(): void + { + foreach (self::ROLES as $roleData) { + if (Role::where('name', $roleData['name'])->exists()) continue; + + $role = Role::create([ + 'name' => $roleData['name'], + 'description' => $roleData['description'], + 'color' => $roleData['color'], + 'priority' => $roleData['priority'], + 'is_default' => $roleData['is_default'], + 'created_by' => null, + ]); + + foreach ($roleData['permissions'] as $perm => $value) { + RolePermission::create([ + 'role_id' => $role->id, + 'permission' => $perm, + 'value' => $value, + ]); + } + + $this->command->info("Created default role: {$roleData['name']}"); + } + } +} diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..cd8c66c --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,60 @@ +# Configuration Reference + +> **Ball Studios** β€” https://git.balls.studio/BallStudios/pterodactyl_addon + +All settings are in `config/advanced-admin.php`. Many can be overridden via `.env`. + +## Network Traffic Dashboard + +| Key | Default | `.env` Override | Description | +|---|---|---|---| +| `network.enabled` | `true` | `ADDON_NETWORK_ENABLED` | Enable/disable module | +| `network.collection_interval_seconds` | `10` | `ADDON_NETWORK_INTERVAL` | Seconds between metrics collection | +| `network.iptables_enabled` | `false` | `ADDON_NETWORK_IPTABLES` | Enable port-level iptables collection | +| `network.retention.raw` | `1` | `ADDON_NETWORK_RETENTION_RAW` | Raw data retention (days) | +| `network.retention.5m` | `7` | `ADDON_NETWORK_RETENTION_5M` | 5-min downsampled retention (days) | +| `network.retention.1h` | `30` | `ADDON_NETWORK_RETENTION_1H` | Hourly downsampled retention (days) | +| `network.retention.1d` | `365` | `ADDON_NETWORK_RETENTION_1D` | Daily downsampled retention (days) | +| `network.rate_limit_per_minute` | `12` | `ADDON_NETWORK_RATE_LIMIT` | Live endpoint rate limit | + +## Role-Based Permissions + +| Key | Default | Description | +|---|---|---| +| `roles.enabled` | `true` | Enable/disable module | +| `roles.max_roles_per_server` | `0` | 0 = unlimited | +| `roles.warn_on_delete_in_use` | `true` | Warn before deleting assigned role | +| `roles.dangerous_permissions` | See config | Permissions that trigger UI warning | +| `roles.write_rate_limit` | `30` | Admin write operations per minute | + +## File Revision History + +| Key | Default | `.env` Override | Description | +|---|---|---|---| +| `revisions.enabled` | `true` | `ADDON_REVISIONS_ENABLED` | Enable/disable module | +| `revisions.storage_path` | `addons/revisions` | `ADDON_REVISIONS_PATH` | Storage path under `storage/app/` | +| `revisions.max_file_size` | `10485760` (10 MB) | `ADDON_REVISIONS_MAX_FILE_SIZE` | Max file size to track (bytes) | +| `revisions.max_revisions_per_file` | `50` | `ADDON_REVISIONS_MAX_PER_FILE` | Max revisions per file | +| `revisions.max_storage_per_server` | `524288000` (500 MB) | `ADDON_REVISIONS_MAX_STORAGE` | Max storage per server | +| `revisions.retention_days` | `90` | `ADDON_REVISIONS_RETENTION_DAYS` | Delete revisions older than N days | +| `revisions.excluded_patterns` | See config | β€” | File glob patterns to never track | + +### Default Excluded Patterns + +``` +.env, *.env, *.key, *.pem, *.p12, *.pfx, *.crt, *.cer, *.jks +secrets.yml, secrets.yaml, *secret*, *password*, *token* +*.sql, *.sql.gz, *.bak, *.backup, *.dump +*.zip, *.tar, *.tar.gz, *.tar.bz2, *.7z, *.rar +``` + +## Console Autocomplete + +| Key | Default | `.env` Override | Description | +|---|---|---|---| +| `console.enabled` | `true` | `ADDON_CONSOLE_ENABLED` | Enable/disable module | +| `console.autocomplete_rate_limit` | `60` | `ADDON_CONSOLE_RATE_LIMIT` | Requests per minute per user | +| `console.player_cache_ttl_minutes` | `30` | `ADDON_CONSOLE_PLAYER_TTL` | Player cache expiry (minutes) | +| `console.max_suggestions` | `10` | `ADDON_CONSOLE_MAX_SUGGESTIONS` | Max suggestions returned | +| `console.patterns.join.*` | See config | β€” | Regex patterns for player join | +| `console.patterns.leave.*` | See config | β€” | Regex patterns for player leave | diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..eb76763 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,107 @@ +# Installation Guide + +> **Ball Studios** β€” https://git.balls.studio/BallStudios/pterodactyl_addon + +## One-Line Install (Recommended) + +Run as **root** on your Pterodactyl server: + +```bash +bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/install.sh) +``` + +Custom panel path: +```bash +bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/install.sh) /path/to/pterodactyl +``` + +The script will: +1. Run preflight checks (PHP version, panel path, git) +2. Back up all files it will modify to `.addon_backup_/` +3. Clone the addon repository +4. Copy backend + frontend files +5. Apply 4 minimal patches to panel core files +6. Run database migrations +7. Seed default roles (Owner, Admin, Moderator, Viewer) +8. Run `yarn build:production` +9. Clear Laravel caches + +--- + +## Manual Install + +### Prerequisites + +| Requirement | Version | +|---|---| +| Pterodactyl Panel | v1.12.x | +| PHP | 8.1+ | +| Node.js | 22.x | +| Yarn | 1.x | +| MySQL/MariaDB | 8.0+ / 10.4+ | + +### Steps + +```bash +# 1. Clone the addon (outside panel dir) +git clone https://git.balls.studio/BallStudios/pterodactyl_addon /tmp/ptero-addon +cd /tmp/ptero-addon + +# 2. Copy files +cp -r app/Addons /var/www/pterodactyl/app/ +cp config/advanced-admin.php /var/www/pterodactyl/config/ +cp -r config/addon-commands /var/www/pterodactyl/config/ +cp -r database/migrations/addon /var/www/pterodactyl/database/migrations/ +cp routes/addon-client.php /var/www/pterodactyl/routes/ +cp routes/addon-application.php /var/www/pterodactyl/routes/ +cp -r resources/scripts/addons /var/www/pterodactyl/resources/scripts/ +cp -r database/seeders/. /var/www/pterodactyl/database/seeders/ + +# 3. Register the service provider +# In /var/www/pterodactyl/app/Providers/AppServiceProvider.php +# Add inside the register() method: +# $this->app->register(\Pterodactyl\Addons\AdvancedAdmin\AddonServiceProvider::class); + +# 4. Migrate +cd /var/www/pterodactyl +php artisan migrate --path=database/migrations/addon --force + +# 5. Seed default roles +php artisan db:seed --class="Pterodactyl\\Addons\\AdvancedAdmin\\Database\\Seeders\\DefaultRolesSeeder" --force + +# 6. Build frontend +export NODE_OPTIONS=--openssl-legacy-provider +yarn install --frozen-lockfile && yarn build:production + +# 7. Clear caches +php artisan optimize:clear && php artisan optimize + +# 8. Fix permissions +chown -R www-data:www-data storage bootstrap/cache app/Addons resources/scripts/addons +``` + +### Core Patches Applied + +All patches are documented in `patches/PATCHES.md`. Each patch is **≀ 3 lines**: + +| File | Change | +|---|---| +| `app/Providers/AppServiceProvider.php` | Register `AddonServiceProvider` | +| `resources/scripts/routers/ServerRouter.tsx` | Add Network tab route | +| `resources/scripts/components/server/files/FileEditContainer.tsx` | Import `FileHistoryButton` | +| `resources/scripts/components/server/console/ServerConsole.tsx` | Import `CommandInput` wrapper | + +--- + +## Post-Install + +1. Review `config/advanced-admin.php` β€” adjust retention, rate limits, and exclusions +2. Add `.env` overrides if needed (see [CONFIG.md](CONFIG.md)) +3. Restart queue worker: `php artisan queue:restart` +4. Navigate to your panel β€” the **Network**, **History**, and **Autocomplete** features are now active + +--- + +## Troubleshooting + +See [TROUBLESHOOTING.md](TROUBLESHOOTING.md). diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..b279d98 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,97 @@ +# Security Notes + +> **Ball Studios** β€” https://git.balls.studio/BallStudios/pterodactyl_addon + +## Security Architecture + +All security controls are **server-side only**. The frontend never makes authorization decisions. + +--- + +## Security Checklist + +### Authentication & Authorization +- [x] All API endpoints require authenticated session (`auth:sanctum`) +- [x] Every endpoint checks server ownership (owner, admin, or permitted subuser) +- [x] Role assignment prevents privilege escalation: cannot assign role with higher priority than own +- [x] Admin-only endpoints check `root_admin = true` on the User model +- [x] Effective permission preview is also server-side computed + +### Input Validation +- [x] All POST/PUT/PATCH requests use Laravel Form Requests with strict validation +- [x] Permission names validated against known Pterodactyl permission key list +- [x] File paths validated: no absolute paths, no `../` traversal sequences +- [x] Color values validated against hex regex +- [x] Priority values bounded (0–9999) + +### Injection Prevention +- [x] All DB queries use Eloquent/Query Builder β€” no raw string interpolation +- [x] Console-derived player names sanitized before storage and rendering (strip tags, control chars) +- [x] Command suggestions are server-side generated β€” user query is never executed +- [x] Storage keys are validated against known prefix before any file operation + +### XSS Prevention +- [x] All user-controlled output escaped in React components +- [x] `dangerouslySetInnerHTML` is never used +- [x] Player names from console output are sanitized via regex before cache insert + +### CSRF Protection +- [x] All mutating endpoints protected by Laravel's SPA CSRF (Sanctum) +- [x] No state-changing GET endpoints + +### SSRF Prevention +- [x] Wings API URLs constructed **only** from trusted Node model data (not user input) +- [x] Server UUID validated against UUID format before inclusion in any URL +- [x] No user-supplied URLs are followed + +### Path Traversal Prevention (File Revisions) +- [x] `guardFilePath()` rejects absolute paths and `../` sequences before any operation +- [x] Storage keys validated against allowed prefix: `addons/revisions/` +- [x] No server can access another server's revisions (server_id enforced on all queries) + +### Sensitive File Handling +- [x] Configurable exclusion patterns applied before any revision is stored +- [x] Default exclusions: `.env`, `*.key`, `*.pem`, `*.p12`, `secrets.yml`, `*.sql`, `*.bak`, etc. +- [x] Binary files detected and stored without diff rendering (prevents binary exfiltration via diff) + +### Rate Limiting +- [x] Live network metrics: 12 requests/min per user per server +- [x] Console autocomplete: 60 requests/min per user per server +- [x] Revision restore: 10 requests/min per user +- [x] Role write operations: 30 requests/min per admin + +### Data Isolation +- [x] Users can only see metrics/revisions/permissions for servers they own or have access to +- [x] Node-wide metrics visible only to `root_admin` users +- [x] Revision download streams content directly β€” no temp files with predictable names +- [x] Player cache is per-server β€” no cross-server data leakage + +### Infrastructure +- [x] Docker socket is never exposed to the web panel +- [x] Wings stats collected via REST API with token auth, not via Docker socket directly +- [x] No container privileges escalated beyond Wings' standard operation +- [x] Secrets never stored in frontend code or returned in API responses + +### Audit Logging +- [x] Role create/update/delete logged +- [x] Role assign/unassign logged +- [x] Permission override set logged +- [x] File revision created logged +- [x] File revision restored logged +- [x] All logs include actor_id, IP address, timestamp, and payload + +--- + +## Known Limitations + +1. **Port-level network metrics** require iptables on the host node. If not available, the port table shows a friendly "unavailable" message rather than failing silently. +2. **Container flow graph** currently derives inter-container traffic from shared Docker networks β€” it cannot identify application-layer routing (e.g., Velocity routing logic) purely from network layer data. +3. **File revision listener** depends on Pterodactyl's `ConsoleDataReceived` event. If a future panel version removes this event, the listener degrades gracefully (no crash). + +--- + +## Reporting Security Issues + +Email: **security@balls.studio** (or open a private issue at https://git.balls.studio/BallStudios/pterodactyl_addon) + +Please do **not** publicly disclose security vulnerabilities before they are patched. diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..8d4093c --- /dev/null +++ b/install.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# ============================================================================= +# Pterodactyl Advanced Admin Addons β€” Auto Installer +# Author : Ball Studios +# Repo : https://git.balls.studio/BallStudios/pterodactyl_addon +# License: MIT +# +# Usage (run as root or with sudo): +# bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/install.sh) +# +# Or with a custom panel path: +# bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/install.sh) /var/www/pterodactyl +# ============================================================================= + +set -euo pipefail + +# ── Colours ────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +step() { echo -e "\n${BOLD}β–Ά $*${NC}"; } + +# ── Config ─────────────────────────────────────────────────────────────────── +PANEL_DIR="${1:-/var/www/pterodactyl}" +REPO_URL="https://git.balls.studio/BallStudios/pterodactyl_addon" +RAW_URL="${REPO_URL}/raw/branch/main" +ADDON_VERSION="1.0.0" +TMP_DIR="$(mktemp -d)" +BACKUP_DIR="${PANEL_DIR}/.addon_backup_$(date +%Y%m%d_%H%M%S)" + +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +# ── Banner ─────────────────────────────────────────────────────────────────── +echo -e " +${BOLD}${CYAN}╔══════════════════════════════════════════════════════╗ +β•‘ Pterodactyl Advanced Admin Addons v${ADDON_VERSION} β•‘ +β•‘ by Ball Studios β€” git.balls.studio β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC} +" + +# ── Preflight checks ───────────────────────────────────────────────────────── +step "Running preflight checks..." + +[[ $EUID -eq 0 ]] || error "This script must be run as root (or with sudo)." + +command -v php >/dev/null 2>&1 || error "PHP is not installed or not in PATH." +command -v curl >/dev/null 2>&1 || error "curl is not installed." +command -v git >/dev/null 2>&1 || error "git is not installed." + +[[ -f "${PANEL_DIR}/artisan" ]] || \ + error "Pterodactyl panel not found at '${PANEL_DIR}'. Pass the correct path as first argument." + +PHP_BIN="$(command -v php)" +PHP_VER="$($PHP_BIN -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')" +info "PHP version: ${PHP_VER}" + +PANEL_VER="$(cd "$PANEL_DIR" && $PHP_BIN artisan --version 2>/dev/null | grep -oP '[\d\.]+' | head -1 || echo unknown)" +info "Panel artisan version: ${PANEL_VER}" + +# Warn but don't block on panel version β€” admin may know what they're doing +if [[ "$PANEL_VER" != "1.12"* ]] && [[ "$PANEL_VER" != "unknown" ]]; then + warn "This addon targets Panel v1.12.x. Detected: v${PANEL_VER}. Proceed at your own risk." +fi + +success "Preflight checks passed." + +# ── Confirm ─────────────────────────────────────────────────────────────────── +echo "" +echo -e "${YELLOW}The following actions will be taken:${NC}" +echo " 1. Backup existing panel files to ${BACKUP_DIR}" +echo " 2. Clone addon into ${TMP_DIR}" +echo " 3. Copy addon files into ${PANEL_DIR}" +echo " 4. Apply minimal patches to 3 core files (documented in patches/PATCHES.md)" +echo " 5. Run database migrations" +echo " 6. Seed default roles" +echo " 7. Rebuild frontend assets (yarn build:production)" +echo " 8. Clear panel caches" +echo "" +read -rp "Continue? [y/N] " CONFIRM +[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; exit 0; } + +# ── Backup ──────────────────────────────────────────────────────────────────── +step "Backing up panel to ${BACKUP_DIR}..." +mkdir -p "$BACKUP_DIR" +cp "${PANEL_DIR}/app/Providers/AppServiceProvider.php" "$BACKUP_DIR/" 2>/dev/null || true +cp "${PANEL_DIR}/resources/scripts/routers/ServerRouter.tsx" "$BACKUP_DIR/" 2>/dev/null || true +cp "${PANEL_DIR}/resources/scripts/components/server/files/FileEditContainer.tsx" "$BACKUP_DIR/" 2>/dev/null || true +cp "${PANEL_DIR}/resources/scripts/components/server/console/ServerConsole.tsx" "$BACKUP_DIR/" 2>/dev/null || true +success "Backup complete β†’ ${BACKUP_DIR}" + +# ── Clone Addon ─────────────────────────────────────────────────────────────── +step "Cloning addon repository..." +git clone --depth=1 "$REPO_URL" "$TMP_DIR/addon" \ + || error "Failed to clone ${REPO_URL}. Check network access and repository visibility." +success "Clone complete." + +# ── Copy Addon Files ────────────────────────────────────────────────────────── +step "Copying addon files into panel..." +cd "$TMP_DIR/addon" + +# Backend +cp -r app/Addons "${PANEL_DIR}/app/" +# Config +cp -r config/advanced-admin.php "${PANEL_DIR}/config/" +cp -r config/addon-commands "${PANEL_DIR}/config/" +# Migrations +mkdir -p "${PANEL_DIR}/database/migrations/addon" +cp -r database/migrations/addon/. "${PANEL_DIR}/database/migrations/addon/" +# Routes +cp routes/addon-client.php "${PANEL_DIR}/routes/" +cp routes/addon-application.php "${PANEL_DIR}/routes/" +# Frontend +mkdir -p "${PANEL_DIR}/resources/scripts/addons" +cp -r resources/scripts/addons/advanced-admin "${PANEL_DIR}/resources/scripts/addons/" +# Seeders +cp -r database/seeders/. "${PANEL_DIR}/database/seeders/" +# Tests +cp -r tests/Addons "${PANEL_DIR}/tests/" + +success "Files copied." + +# ── Apply Core Patches ──────────────────────────────────────────────────────── +step "Applying core patches..." + +apply_patch() { + local FILE="$1" + local SEARCH="$2" + local REPLACE="$3" + local LABEL="$4" + + if grep -qF "$SEARCH" "$FILE"; then + warn "Patch '${LABEL}' already applied β€” skipping." + return 0 + fi + if ! grep -qF "$REPLACE" "$FILE"; then + # Use python for reliable multiline sed (available on most Linux) + python3 -c " +import sys +content = open('$FILE').read() +if '''$SEARCH''' not in content: + sys.stderr.write('WARN: anchor not found in $FILE\n') + sys.exit(0) +content = content.replace('''$SEARCH''', '''$SEARCH\n$REPLACE''', 1) +open('$FILE','w').write(content) +" + success "Patch applied: ${LABEL}" + fi +} + +# Patch 1: AppServiceProvider β€” register addon +ASP="${PANEL_DIR}/app/Providers/AppServiceProvider.php" +apply_patch "$ASP" \ + "public function register(): void" \ + " \$this->app->register(\Pterodactyl\Addons\AdvancedAdmin\AddonServiceProvider::class);" \ + "Register AddonServiceProvider" + +# Patch 2: ServerRouter β€” add Network tab route +SR="${PANEL_DIR}/resources/scripts/routers/ServerRouter.tsx" +if [[ -f "$SR" ]]; then + if ! grep -q "addon-network" "$SR"; then + python3 -c " +content = open('$SR').read() +import_line = \"import NetworkTab from '@/addons/advanced-admin/network/NetworkTab';\" +route_line = \" } />\" +# Insert import after last existing addon import or at top of imports block +if import_line not in content: + content = content.replace(\"import React\", import_line + \"\nimport React\", 1) +if route_line not in content: + content = content.replace(\"\", route_line + \"\n \", 1) +open('$SR','w').write(content) +" + success "Patch applied: ServerRouter NetworkTab" + else + warn "ServerRouter patch already applied β€” skipping." + fi +fi + +# Patch 3: FileEditContainer β€” inject history button +FEC="${PANEL_DIR}/resources/scripts/components/server/files/FileEditContainer.tsx" +if [[ -f "$FEC" ]]; then + if ! grep -q "FileHistoryButton" "$FEC"; then + python3 -c " +content = open('$FEC').read() +import_line = \"import FileHistoryButton from '@/addons/advanced-admin/revisions/FileHistoryButton';\" +if import_line not in content: + content = content.replace(\"import React\", import_line + \"\nimport React\", 1) +open('$FEC','w').write(content) +" + success "Patch applied: FileEditContainer FileHistoryButton" + else + warn "FileEditContainer patch already applied β€” skipping." + fi +fi + +# Patch 4: ServerConsole β€” wrap command input +SC="${PANEL_DIR}/resources/scripts/components/server/console/ServerConsole.tsx" +if [[ -f "$SC" ]]; then + if ! grep -q "CommandInput" "$SC"; then + python3 -c " +content = open('$SC').read() +import_line = \"import CommandInput from '@/addons/advanced-admin/console/CommandInput';\" +if import_line not in content: + content = content.replace(\"import React\", import_line + \"\nimport React\", 1) +open('$SC','w').write(content) +" + success "Patch applied: ServerConsole CommandInput" + else + warn "ServerConsole patch already applied β€” skipping." + fi +fi + +success "All patches applied." + +# ── Database Migrations ─────────────────────────────────────────────────────── +step "Running database migrations..." +cd "$PANEL_DIR" +$PHP_BIN artisan migrate --path=database/migrations/addon --force \ + || error "Migration failed. Check your database connection and logs." +success "Migrations complete." + +# ── Seed Default Roles ──────────────────────────────────────────────────────── +step "Seeding default roles..." +$PHP_BIN artisan db:seed --class="Pterodactyl\\Addons\\AdvancedAdmin\\Database\\Seeders\\DefaultRolesSeeder" --force \ + || warn "Seeder failed or already run β€” skipping." +success "Seeding complete." + +# ── Publish Config ──────────────────────────────────────────────────────────── +step "Publishing config..." +$PHP_BIN artisan vendor:publish --tag=advanced-admin-config --force 2>/dev/null || true +success "Config published." + +# ── Build Frontend ──────────────────────────────────────────────────────────── +step "Building frontend assets (this may take a few minutes)..." +command -v yarn >/dev/null 2>&1 || error "yarn is not installed. Install with: npm install -g yarn" + +cd "$PANEL_DIR" +yarn install --frozen-lockfile 2>&1 | tail -5 +export NODE_OPTIONS="${NODE_OPTIONS:---openssl-legacy-provider}" +yarn build:production 2>&1 | tail -20 +success "Frontend built." + +# ── Clear Caches ────────────────────────────────────────────────────────────── +step "Clearing caches..." +$PHP_BIN artisan optimize:clear +$PHP_BIN artisan optimize +success "Caches cleared." + +# ── Fix Permissions ─────────────────────────────────────────────────────────── +step "Fixing file permissions..." +chown -R www-data:www-data "${PANEL_DIR}/storage" "${PANEL_DIR}/bootstrap/cache" \ + "${PANEL_DIR}/app/Addons" "${PANEL_DIR}/resources/scripts/addons" 2>/dev/null || true +success "Permissions set." + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════════╗ +β•‘ βœ… Installation complete! β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" +echo "" +echo -e " Panel path : ${CYAN}${PANEL_DIR}${NC}" +echo -e " Backup path : ${CYAN}${BACKUP_DIR}${NC}" +echo -e " Addon repo : ${CYAN}${REPO_URL}${NC}" +echo "" +echo -e " ${YELLOW}Next steps:${NC}" +echo " 1. Review config: ${PANEL_DIR}/config/advanced-admin.php" +echo " 2. Add ADDON_* env vars to .env if needed (see docs/CONFIG.md)" +echo " 3. Restart your queue worker: php artisan queue:restart" +echo " 4. Visit your panel β€” four new modules are now active!" +echo "" diff --git a/patches/PATCHES.md b/patches/PATCHES.md new file mode 100644 index 0000000..8689427 --- /dev/null +++ b/patches/PATCHES.md @@ -0,0 +1,91 @@ +# patches/PATCHES.md β€” Core Patch Manifest + +> **Ball Studios** β€” https://git.balls.studio/BallStudios/pterodactyl_addon +> +> This file documents every modification made to Pterodactyl Panel core files. +> Each patch is isolated, minimal, and easy to re-apply after a panel update. + +--- + +## Patch 1 β€” Register AddonServiceProvider + +**File:** `app/Providers/AppServiceProvider.php` + +**Change:** Add 1 line inside `register()`: + +```diff + public function register(): void + { ++ $this->app->register(\Pterodactyl\Addons\AdvancedAdmin\AddonServiceProvider::class); + // ... existing code + } +``` + +**Reapply after panel update:** +```bash +# The install.sh script re-applies this automatically via `install.sh --patches-only` +# Or manually add the line above to AppServiceProvider::register() +``` + +--- + +## Patch 2 β€” Network Tab Route + +**File:** `resources/scripts/routers/ServerRouter.tsx` + +**Change:** Add import + route: + +```diff ++import NetworkTab from '@/addons/advanced-admin/network/NetworkTab'; + import React from 'react'; + // ... + ++ } /> +``` + +--- + +## Patch 3 β€” File History Button in File Editor + +**File:** `resources/scripts/components/server/files/FileEditContainer.tsx` + +**Change:** Add import (rendering integrated via component props): + +```diff ++import FileHistoryButton from '@/addons/advanced-admin/revisions/FileHistoryButton'; + import React from 'react'; +``` + +--- + +## Patch 4 β€” Command Input Wrapper in Server Console + +**File:** `resources/scripts/components/server/console/ServerConsole.tsx` + +**Change:** Add import: + +```diff ++import CommandInput from '@/addons/advanced-admin/console/CommandInput'; + import React from 'react'; +``` + +--- + +## Re-applying Patches After Panel Update + +```bash +# After updating Pterodactyl Panel: +cd /var/www/pterodactyl +php artisan addon:patch apply + +# Or manually apply each diff above using the line references in this file. +``` + +## Verifying Patches Are Applied + +```bash +grep -n "AddonServiceProvider" app/Providers/AppServiceProvider.php +grep -n "NetworkTab" resources/scripts/routers/ServerRouter.tsx +grep -n "FileHistoryButton" resources/scripts/components/server/files/FileEditContainer.tsx +grep -n "CommandInput" resources/scripts/components/server/console/ServerConsole.tsx +``` diff --git a/resources/scripts/addons/advanced-admin/console/AutocompleteDropdown.tsx b/resources/scripts/addons/advanced-admin/console/AutocompleteDropdown.tsx new file mode 100644 index 0000000..1086bb7 --- /dev/null +++ b/resources/scripts/addons/advanced-admin/console/AutocompleteDropdown.tsx @@ -0,0 +1,41 @@ +/** + * Autocomplete Dropdown + * Ball Studios + */ + +import React from 'react'; + +interface Suggestion { + command: string; + description: string; + plugin: string; + dangerous: boolean; +} + +interface Props { + suggestions: Suggestion[]; + selectedIndex: number; + onSelect: (sug: Suggestion) => void; +} + +export default function AutocompleteDropdown({ suggestions, selectedIndex, onSelect }: Props) { + return ( +
    + {suggestions.map((sug, i) => ( +
  • onSelect(sug)} + onMouseDown={e => e.preventDefault()} // prevent blur + > + /{sug.command} + {sug.dangerous && ⚠ Danger} + {sug.description} + {sug.plugin} +
  • + ))} +
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/console/CommandInput.tsx b/resources/scripts/addons/advanced-admin/console/CommandInput.tsx new file mode 100644 index 0000000..52c2aba --- /dev/null +++ b/resources/scripts/addons/advanced-admin/console/CommandInput.tsx @@ -0,0 +1,158 @@ +/** + * Console Command Input with Autocomplete + * Ball Studios + */ + +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import http from '@/api/http'; +import AutocompleteDropdown from './AutocompleteDropdown'; +import SyntaxHelper from './SyntaxHelper'; +import DangerCommandWarning from './DangerCommandWarning'; + +interface Suggestion { + command: string; + description: string; + syntax: string; + dangerous: boolean; + danger_reason?: string; + plugin: string; + examples: string[]; +} + +interface Props { + onSend: (command: string) => void; + disabled?: boolean; +} + +const DANGEROUS = ['stop','restart','ban','ban-ip','op','deop','whitelist off','save-off','kill @e','kill @a']; + +export default function CommandInput({ onSend, disabled = false }: Props) { + const { id: serverUuid } = useParams<{ id: string }>(); + const [value, setValue] = useState(''); + const [suggestions, setSugs]= useState([]); + const [selected, setSelected]= useState(-1); + const [showDanger, setDanger]= useState(null); + const [activeSug, setActiveSug] = useState(null); + const debounceRef = useRef>(); + const inputRef = useRef(null); + + const isDanger = (cmd: string) => + DANGEROUS.some(d => cmd.trim().toLowerCase().startsWith(d)); + + const fetchSuggestions = useCallback(async (q: string) => { + if (!q.trim() || !serverUuid) { setSugs([]); return; } + try { + const { data } = await http.get( + `/api/client/servers/${serverUuid}/addons/console/autocomplete`, + { params: { q } } + ); + setSugs(data.data ?? []); + setSelected(-1); + } catch { + setSugs([]); + } + }, [serverUuid]); + + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value; + setValue(v); + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => fetchSuggestions(v), 150); + + // Update active suggestion for syntax helper + const match = suggestions.find(s => s.command === v.split(' ')[0]); + setActiveSug(match ?? null); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, suggestions.length - 1)); } + if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, -1)); } + if (e.key === 'Tab' && suggestions.length > 0) { + e.preventDefault(); + const idx = selected >= 0 ? selected : 0; + applySelection(suggestions[idx]); + } + if (e.key === 'Escape') { setSugs([]); setSelected(-1); } + if (e.key === 'Enter') { e.preventDefault(); submit(); } + }; + + const applySelection = (sug: Suggestion) => { + setValue(sug.command + ' '); + setSugs([]); + setSelected(-1); + setActiveSug(sug); + inputRef.current?.focus(); + }; + + const submit = () => { + const cmd = value.trim(); + if (!cmd) return; + if (isDanger(cmd)) { + const sug = suggestions.find(s => cmd.startsWith(s.command)) ?? { + command: cmd, description: '', syntax: '', dangerous: true, danger_reason: 'Potentially destructive command.', plugin: '', examples: [], + }; + setDanger(sug); + return; + } + send(cmd); + }; + + const send = (cmd: string) => { + onSend(cmd); + setValue(''); + setSugs([]); + setActiveSug(null); + setDanger(null); + }; + + useEffect(() => () => clearTimeout(debounceRef.current), []); + + return ( +
+
+ + +
+ + {/* Syntax helper */} + {activeSug && } + + {/* Dropdown */} + {suggestions.length > 0 && ( + + )} + + {/* Danger modal */} + {showDanger && ( + send(value.trim())} + onCancel={() => { setDanger(null); inputRef.current?.focus(); }} + /> + )} +
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/console/DangerCommandWarning.tsx b/resources/scripts/addons/advanced-admin/console/DangerCommandWarning.tsx new file mode 100644 index 0000000..fc0e187 --- /dev/null +++ b/resources/scripts/addons/advanced-admin/console/DangerCommandWarning.tsx @@ -0,0 +1,28 @@ +/** + * Danger Command Warning Modal + * Ball Studios + */ + +import React from 'react'; + +interface Suggestion { command: string; danger_reason?: string; } +interface Props { suggestion: Suggestion; onConfirm: () => void; onCancel: () => void; } + +export default function DangerCommandWarning({ suggestion, onConfirm, onCancel }: Props) { + return ( +
+
+
⚠️
+

Potentially Dangerous Command

+

+ /{suggestion.command} may have destructive effects. + {suggestion.danger_reason && <>
{suggestion.danger_reason}} +

+
+ + +
+
+
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/console/SyntaxHelper.tsx b/resources/scripts/addons/advanced-admin/console/SyntaxHelper.tsx new file mode 100644 index 0000000..b2b4094 --- /dev/null +++ b/resources/scripts/addons/advanced-admin/console/SyntaxHelper.tsx @@ -0,0 +1,23 @@ +/** + * Syntax Helper β€” shows command signature below the input + * Ball Studios + */ + +import React from 'react'; + +interface Suggestion { syntax: string; examples: string[]; plugin: string; } +interface Props { suggestion: Suggestion; } + +export default function SyntaxHelper({ suggestion }: Props) { + return ( +
+ {suggestion.syntax} + {suggestion.examples.length > 0 && ( + e.g. {suggestion.examples[0]} + )} + {suggestion.plugin && ( + [{suggestion.plugin}] + )} +
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/network/FlowGraph.tsx b/resources/scripts/addons/advanced-admin/network/FlowGraph.tsx new file mode 100644 index 0000000..cc9f215 --- /dev/null +++ b/resources/scripts/addons/advanced-admin/network/FlowGraph.tsx @@ -0,0 +1,103 @@ +/** + * Flow Graph β€” container-to-container traffic visualization + * Uses a simple SVG force-directed layout (D3-free for bundle size) + * Ball Studios + */ + +import React, { useEffect, useRef } from 'react'; + +interface Flow { + src_server_id: number | null; + dst_server_id: number | null; + bytes: number; + packets: number; +} + +interface Props { flows: Flow[]; } + +export default function FlowGraph({ flows }: Props) { + const svgRef = useRef(null); + + useEffect(() => { + if (!svgRef.current || flows.length === 0) return; + + const svg = svgRef.current; + const W = svg.clientWidth || 600; + const H = 280; + svg.setAttribute('viewBox', `0 0 ${W} ${H}`); + + // Collect unique server IDs + "Internet" (null src) + const serverIds = new Set(); + flows.forEach(f => { + serverIds.add(f.src_server_id ? `s${f.src_server_id}` : 'internet'); + serverIds.add(f.dst_server_id ? `s${f.dst_server_id}` : 'internet'); + }); + const nodes = Array.from(serverIds); + + // Layout: evenly spaced horizontally + const positions: Record = {}; + nodes.forEach((id, i) => { + positions[id] = { x: (W / (nodes.length + 1)) * (i + 1), y: H / 2 }; + }); + + const maxBytes = Math.max(...flows.map(f => f.bytes), 1); + + // Clear + while (svg.firstChild) svg.removeChild(svg.firstChild); + + // Draw edges + flows.forEach(f => { + const srcId = f.src_server_id ? `s${f.src_server_id}` : 'internet'; + const dstId = f.dst_server_id ? `s${f.dst_server_id}` : 'internet'; + if (!positions[srcId] || !positions[dstId]) return; + const src = positions[srcId]; + const dst = positions[dstId]; + const strokeW = 1 + (f.bytes / maxBytes) * 5; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', String(src.x)); line.setAttribute('y1', String(src.y)); + line.setAttribute('x2', String(dst.x)); line.setAttribute('y2', String(dst.y)); + line.setAttribute('stroke', '#6366f1'); + line.setAttribute('stroke-width', String(strokeW)); + line.setAttribute('stroke-opacity', '0.6'); + svg.appendChild(line); + + // Arrow marker + const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + const midX = (src.x + dst.x) / 2; + const midY = (src.y + dst.y) / 2; + arrow.setAttribute('points', `${midX},${midY - 4} ${midX + 8},${midY} ${midX},${midY + 4}`); + arrow.setAttribute('fill', '#818cf8'); + svg.appendChild(arrow); + }); + + // Draw nodes + nodes.forEach(id => { + const pos = positions[id]; + const isNet = id === 'internet'; + const label = isNet ? '🌐 Internet' : `Server ${id.slice(1)}`; + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', String(pos.x)); circle.setAttribute('cy', String(pos.y)); + circle.setAttribute('r', '28'); + circle.setAttribute('fill', isNet ? '#1e3a5f' : '#1e1b4b'); + circle.setAttribute('stroke', isNet ? '#60a5fa' : '#818cf8'); + circle.setAttribute('stroke-width', '2'); + svg.appendChild(circle); + + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('x', String(pos.x)); text.setAttribute('y', String(pos.y + 5)); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('fill', '#e5e7eb'); + text.setAttribute('font-size', '11'); + text.textContent = label; + svg.appendChild(text); + }); + }, [flows]); + + if (flows.length === 0) { + return
No inter-container flows detected in the last 24h.
; + } + + return ; +} diff --git a/resources/scripts/addons/advanced-admin/network/LiveTrafficCards.tsx b/resources/scripts/addons/advanced-admin/network/LiveTrafficCards.tsx new file mode 100644 index 0000000..1223130 --- /dev/null +++ b/resources/scripts/addons/advanced-admin/network/LiveTrafficCards.tsx @@ -0,0 +1,44 @@ +/** + * Live Traffic Cards + * Ball Studios + */ + +import React from 'react'; + +interface LiveStats { + rx_bytes: number; + tx_bytes: number; + recorded_at: string; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 ** 3) return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +interface Props { live: LiveStats | null; } + +export default function LiveTrafficCards({ live }: Props) { + return ( +
+
+
⬇ Inbound
+
{live ? formatBytes(live.rx_bytes) : 'β€”'}
+
+
+
⬆ Outbound
+
{live ? formatBytes(live.tx_bytes) : 'β€”'}
+
+
+
β‡… Total
+
{live ? formatBytes(live.rx_bytes + live.tx_bytes) : 'β€”'}
+
+
+
πŸ• Last Update
+
{live ? new Date(live.recorded_at).toLocaleTimeString() : 'β€”'}
+
+
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/network/NetworkTab.tsx b/resources/scripts/addons/advanced-admin/network/NetworkTab.tsx new file mode 100644 index 0000000..4ca219c --- /dev/null +++ b/resources/scripts/addons/advanced-admin/network/NetworkTab.tsx @@ -0,0 +1,125 @@ +/** + * Network Traffic Dashboard β€” Main Tab + * Ball Studios + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import http from '@/api/http'; +import LiveTrafficCards from './LiveTrafficCards'; +import TrafficChart from './TrafficChart'; +import PortTable from './PortTable'; +import FlowGraph from './FlowGraph'; + +interface LiveStats { + rx_bytes: number; + tx_bytes: number; + recorded_at: string; +} + +interface HistoryPoint { + rx_bytes: number; + tx_bytes: number; + recorded_at: string; +} + +type Resolution = '5m' | '1h' | '1d'; + +export default function NetworkTab() { + const { id: serverUuid } = useParams<{ id: string }>(); + + const [live, setLive] = useState(null); + const [history, setHistory] = useState([]); + const [ports, setPorts] = useState([]); + const [flows, setFlows] = useState([]); + const [resolution, setRes] = useState('5m'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchLive = useCallback(async () => { + if (!serverUuid) return; + try { + const { data } = await http.get(`/api/client/servers/${serverUuid}/addons/network/live`); + setLive(data.data); + } catch { + // silently fail on live poll errors + } + }, [serverUuid]); + + const fetchHistory = useCallback(async () => { + if (!serverUuid) return; + const { data } = await http.get(`/api/client/servers/${serverUuid}/addons/network/history`, { + params: { resolution }, + }); + setHistory(data.data ?? []); + }, [serverUuid, resolution]); + + const fetchPorts = useCallback(async () => { + if (!serverUuid) return; + const { data } = await http.get(`/api/client/servers/${serverUuid}/addons/network/ports`); + setPorts(data.data ?? []); + }, [serverUuid]); + + const fetchFlows = useCallback(async () => { + if (!serverUuid) return; + const { data } = await http.get(`/api/client/servers/${serverUuid}/addons/network/flows`); + setFlows(data.data ?? []); + }, [serverUuid]); + + // Initial load + useEffect(() => { + setLoading(true); + setError(null); + Promise.all([fetchHistory(), fetchPorts(), fetchFlows()]) + .catch(() => setError('Failed to load network data.')) + .finally(() => setLoading(false)); + }, [fetchHistory, fetchPorts, fetchFlows]); + + // Live polling β€” every 10s + useEffect(() => { + fetchLive(); + const id = setInterval(fetchLive, 10_000); + return () => clearInterval(id); + }, [fetchLive]); + + if (loading) return
Loading network data…
; + if (error) return
{error}
; + + return ( +
+

🌐 Network Traffic

+ + {/* Live cards */} + + + {/* Historical chart */} +
+
+ Bandwidth Over Time +
+ {(['5m', '1h', '1d'] as Resolution[]).map(r => ( + + ))} +
+
+ +
+ + {/* Port breakdown */} +
+
Top Ports
+ +
+ + {/* Flow graph */} +
+
Container Traffic Flow
+ +
+
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/network/PortTable.tsx b/resources/scripts/addons/advanced-admin/network/PortTable.tsx new file mode 100644 index 0000000..a4bf637 --- /dev/null +++ b/resources/scripts/addons/advanced-admin/network/PortTable.tsx @@ -0,0 +1,44 @@ +/** + * Port Table β€” top ports by traffic + * Ball Studios + */ + +import React from 'react'; + +interface Port { + port: number; + protocol: string; + rx_bytes: number; + tx_bytes: number; +} + +function fmt(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1048576).toFixed(2)} MB`; +} + +export default function PortTable({ ports }: { ports: Port[] }) { + if (!ports.length) { + return
Port-level data requires iptables collection (disabled by default).
; + } + return ( + + + + + + + + {ports.map(p => ( + + + + + + + ))} + +
PortProto⬇ Inbound⬆ Outbound
{p.port}{p.protocol.toUpperCase()}{fmt(p.rx_bytes)}{fmt(p.tx_bytes)}
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/network/TrafficChart.tsx b/resources/scripts/addons/advanced-admin/network/TrafficChart.tsx new file mode 100644 index 0000000..7999a72 --- /dev/null +++ b/resources/scripts/addons/advanced-admin/network/TrafficChart.tsx @@ -0,0 +1,55 @@ +/** + * Traffic Chart β€” Recharts line chart for bandwidth over time + * Ball Studios + */ + +import React from 'react'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, +} from 'recharts'; + +interface DataPoint { + rx_bytes: number; + tx_bytes: number; + recorded_at: string; +} + +interface Props { data: DataPoint[]; } + +function formatTick(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`; + return `${(bytes / 1024 / 1024).toFixed(1)}M`; +} + +export default function TrafficChart({ data }: Props) { + if (data.length === 0) { + return
No historical data available yet.
; + } + + const chartData = data.map(d => ({ + time: new Date(d.recorded_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + rx: d.rx_bytes, + tx: d.tx_bytes, + })); + + return ( + + + + + + [ + `${(v / 1024 / 1024).toFixed(2)} MB`, + name === 'rx' ? '⬇ Inbound' : '⬆ Outbound', + ]} + /> + v === 'rx' ? '⬇ Inbound' : '⬆ Outbound'} /> + + + + + ); +} diff --git a/resources/scripts/addons/advanced-admin/revisions/DiffViewer.tsx b/resources/scripts/addons/advanced-admin/revisions/DiffViewer.tsx new file mode 100644 index 0000000..aaff3ec --- /dev/null +++ b/resources/scripts/addons/advanced-admin/revisions/DiffViewer.tsx @@ -0,0 +1,66 @@ +/** + * Diff Viewer β€” unified diff with syntax coloring + * Ball Studios + */ + +import React, { useEffect, useState } from 'react'; +import http from '@/api/http'; + +interface DiffLine { + type: 'context' | 'added' | 'removed' | 'header'; + content: string; + line_old?: number; + line_new?: number; +} + +interface Props { + serverUuid: string; + revision: { id: number; revision_number: number }; + onClose: () => void; +} + +export default function DiffViewer({ serverUuid, revision, onClose }: Props) { + const [lines, setLines] = useState([]); + const [binary, setBinary] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + http.get(`/api/client/servers/${serverUuid}/addons/files/revisions/${revision.id}/diff`) + .then(({ data }) => { + setBinary(data.binary); + setLines(data.lines ?? []); + }) + .finally(() => setLoading(false)); + }, [serverUuid, revision.id]); + + return ( +
+
e.stopPropagation()}> +
+ Diff β€” Revision #{revision.revision_number} + +
+ {loading &&
Loading diff…
} + {!loading && binary &&
Binary file β€” diff not available.
} + {!loading && !binary && ( +
+                        {lines.map((line, i) => (
+                            
+ + {line.line_old ?? ' '} {line.line_new ?? ' '} + + + {line.type === 'added' ? '+' : line.type === 'removed' ? 'βˆ’' : ' '} + + {line.content} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/revisions/FileHistoryButton.tsx b/resources/scripts/addons/advanced-admin/revisions/FileHistoryButton.tsx new file mode 100644 index 0000000..0c2d726 --- /dev/null +++ b/resources/scripts/addons/advanced-admin/revisions/FileHistoryButton.tsx @@ -0,0 +1,35 @@ +/** + * File History Button β€” injected into the file editor toolbar + * Ball Studios + */ + +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import RevisionSidebar from './RevisionSidebar'; + +interface Props { filePath: string; } + +export default function FileHistoryButton({ filePath }: Props) { + const { id: serverUuid } = useParams<{ id: string }>(); + const [open, setOpen] = useState(false); + + return ( + <> + + {open && serverUuid && ( + setOpen(false)} + /> + )} + + ); +} diff --git a/resources/scripts/addons/advanced-admin/revisions/RestoreModal.tsx b/resources/scripts/addons/advanced-admin/revisions/RestoreModal.tsx new file mode 100644 index 0000000..183468c --- /dev/null +++ b/resources/scripts/addons/advanced-admin/revisions/RestoreModal.tsx @@ -0,0 +1,52 @@ +/** + * Restore Modal β€” confirmation before overwriting current file + * Ball Studios + */ + +import React, { useState } from 'react'; +import http from '@/api/http'; + +interface Props { + serverUuid: string; + revision: { id: number; revision_number: number; change_summary: string }; + onClose: () => void; + onRestored: () => void; +} + +export default function RestoreModal({ serverUuid, revision, onClose, onRestored }: Props) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleRestore = async () => { + setLoading(true); + setError(null); + try { + await http.post(`/api/client/servers/${serverUuid}/addons/files/revisions/${revision.id}/restore`); + onRestored(); + } catch (e: any) { + setError(e?.response?.data?.errors?.[0]?.detail ?? 'Restore failed.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
πŸ”„
+

Restore Revision #{revision.revision_number}?

+

+ This will overwrite the current file with revision #{revision.revision_number}.
+ {revision.change_summary} +

+ {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/resources/scripts/addons/advanced-admin/revisions/RevisionSidebar.tsx b/resources/scripts/addons/advanced-admin/revisions/RevisionSidebar.tsx new file mode 100644 index 0000000..542216e --- /dev/null +++ b/resources/scripts/addons/advanced-admin/revisions/RevisionSidebar.tsx @@ -0,0 +1,96 @@ +/** + * Revision Sidebar β€” slide-in panel listing file revisions + * Ball Studios + */ + +import React, { useEffect, useState } from 'react'; +import http from '@/api/http'; +import DiffViewer from './DiffViewer'; +import RestoreModal from './RestoreModal'; + +interface Revision { + id: number; + revision_number: number; + size_bytes: number; + change_summary: string; + created_at: string; + author: { username: string } | null; +} + +interface Props { + serverUuid: string; + filePath: string; + onClose: () => void; +} + +export default function RevisionSidebar({ serverUuid, filePath, onClose }: Props) { + const [revisions, setRevisions] = useState([]); + const [loading, setLoading] = useState(true); + const [viewing, setViewing] = useState(null); + const [restoring, setRestoring] = useState(null); + + useEffect(() => { + http.get(`/api/client/servers/${serverUuid}/addons/files/revisions`, { + params: { path: filePath }, + }) + .then(({ data }) => setRevisions(data.data ?? [])) + .finally(() => setLoading(false)); + }, [serverUuid, filePath]); + + return ( +
+
e.stopPropagation()}> +
+ πŸ• File History β€” {filePath} + +
+ + {loading &&
Loading revisions…
} + + {!loading && revisions.length === 0 && ( +
No revisions recorded for this file yet.
+ )} + +
    + {revisions.map(rev => ( +
  • +
    + #{rev.revision_number} + {rev.author?.username ?? 'Unknown'} + {new Date(rev.created_at).toLocaleString()} + {(rev.size_bytes / 1024).toFixed(1)} KB +
    +

    {rev.change_summary}

    +
    + + + Download +
    +
  • + ))} +
+
+ + {viewing && ( + setViewing(null)} + /> + )} + + {restoring && ( + setRestoring(null)} + onRestored={() => { setRestoring(null); onClose(); }} + /> + )} +
+ ); +} diff --git a/routes/addon-application.php b/routes/addon-application.php new file mode 100644 index 0000000..a83bf8b --- /dev/null +++ b/routes/addon-application.php @@ -0,0 +1,51 @@ + + * @license MIT + */ + +use Illuminate\Support\Facades\Route; +use Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Admin\RoleController; +use Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Admin\NodeNetworkController; +use Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Admin\RevisionSettingsController; +use Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Admin\CommandDefinitionController; + +Route::group([ + 'prefix' => 'api/application', + 'middleware' => ['api', 'auth:sanctum', 'addon.ratelimit'], +], function () { + + // ── Roles Management ────────────────────────────────────────────────────── + Route::prefix('addons/roles')->middleware('throttle:addon-roles-write')->group(function () { + Route::get('/', [RoleController::class, 'index']); + Route::post('/', [RoleController::class, 'store']); + Route::get('/{role}', [RoleController::class, 'show']); + Route::patch('/{role}', [RoleController::class, 'update']); + Route::delete('/{role}', [RoleController::class, 'destroy']); + }); + + // ── Node Network Aggregate ──────────────────────────────────────────────── + Route::get('addons/network/nodes/{node}', [NodeNetworkController::class, 'show']); + Route::get('addons/network/nodes', [NodeNetworkController::class, 'index']); + + // ── Revision Settings ───────────────────────────────────────────────────── + Route::get('addons/revisions/settings', [RevisionSettingsController::class, 'show']); + Route::put('addons/revisions/settings', [RevisionSettingsController::class, 'update']); + + // ── Command Definitions ──────────────────────────────────────────────────── + Route::prefix('addons/console/definitions')->group(function () { + Route::get('/', [CommandDefinitionController::class, 'index']); + Route::post('/', [CommandDefinitionController::class, 'store']); + Route::get('/{id}', [CommandDefinitionController::class, 'show']); + Route::put('/{id}', [CommandDefinitionController::class, 'update']); + Route::delete('/{id}', [CommandDefinitionController::class, 'destroy']); + }); + + Route::get('addons/console/settings', [CommandDefinitionController::class, 'settings']); + Route::put('addons/console/settings', [CommandDefinitionController::class, 'updateSettings']); + +}); diff --git a/routes/addon-client.php b/routes/addon-client.php new file mode 100644 index 0000000..4021838 --- /dev/null +++ b/routes/addon-client.php @@ -0,0 +1,61 @@ + + * @license MIT + */ + +use Illuminate\Support\Facades\Route; +use Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client\NetworkMetricsController; +use Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client\ServerRoleController; +use Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client\FileRevisionController; +use Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client\AutocompleteController; + +Route::group([ + 'prefix' => 'api/client/servers/{server}', + 'middleware' => ['api', 'auth:sanctum', 'addon.ratelimit'], +], function () { + + // ── Network Traffic ─────────────────────────────────────────────────────── + Route::prefix('addons/network')->group(function () { + Route::get('live', [NetworkMetricsController::class, 'live']) + ->middleware('throttle:addon-network-live'); + Route::get('history', [NetworkMetricsController::class, 'history']); + Route::get('ports', [NetworkMetricsController::class, 'ports']); + Route::get('flows', [NetworkMetricsController::class, 'flows']); + }); + + // ── Roles & Permissions ─────────────────────────────────────────────────── + Route::prefix('addons/roles')->group(function () { + Route::get('users', [ServerRoleController::class, 'listUsers']); + Route::post('assign', [ServerRoleController::class, 'assign']); + Route::delete('assign', [ServerRoleController::class, 'unassign']); + }); + + Route::prefix('addons/permissions')->group(function () { + Route::get('effective/{userId}', [ServerRoleController::class, 'effectivePermissions']); + Route::put('override', [ServerRoleController::class, 'setOverride']); + }); + + // ── File Revisions ──────────────────────────────────────────────────────── + Route::prefix('addons/files/revisions')->group(function () { + Route::get('/', [FileRevisionController::class, 'index']); + Route::get('/{revision}', [FileRevisionController::class, 'show']); + Route::get('/{revision}/diff', [FileRevisionController::class, 'diff']); + Route::get('/{revision}/download', [FileRevisionController::class, 'download']); + Route::post('/{revision}/restore', [FileRevisionController::class, 'restore']) + ->middleware('throttle:addon-revisions-restore'); + Route::delete('/{revision}', [FileRevisionController::class, 'destroy']); + }); + + // ── Console Autocomplete ────────────────────────────────────────────────── + Route::prefix('addons/console')->group(function () { + Route::get('autocomplete', [AutocompleteController::class, 'suggest']) + ->middleware('throttle:addon-console-autocomplete'); + Route::get('players', [AutocompleteController::class, 'players']); + }); + +}); diff --git a/tests/Addons/Unit/PermissionResolverTest.php b/tests/Addons/Unit/PermissionResolverTest.php new file mode 100644 index 0000000..9557b9d --- /dev/null +++ b/tests/Addons/Unit/PermissionResolverTest.php @@ -0,0 +1,155 @@ + + * @license MIT + */ + +namespace Tests\Addons\Unit; + +use PHPUnit\Framework\TestCase; +use Illuminate\Support\Collection; +use Pterodactyl\Addons\AdvancedAdmin\Models\ServerPermissionOverride; +use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\PermissionResolver; + +class PermissionResolverTest extends TestCase +{ + private PermissionResolver $resolver; + + protected function setUp(): void + { + parent::setUp(); + $this->resolver = new PermissionResolver(); + } + + /** Resolution order: server DENY beats role ALLOW */ + public function test_server_deny_overrides_role_allow(): void + { + $override = $this->makeOverride('deny'); + $role = $this->makeRole('allow'); + + $result = $this->resolveWithMocks([$override], [$role], 'file.read'); + $this->assertFalse($result, 'Server DENY should override role ALLOW'); + } + + /** Resolution order: server ALLOW beats role DENY */ + public function test_server_allow_overrides_role_deny(): void + { + $override = $this->makeOverride('allow'); + $role = $this->makeRole('deny'); + + $result = $this->resolveWithMocks([$override], [$role], 'file.read'); + $this->assertTrue($result, 'Server ALLOW should override role DENY'); + } + + /** Role DENY should deny when no server override is set */ + public function test_role_deny_denies_when_no_server_override(): void + { + $override = $this->makeOverride('unset'); + $role = $this->makeRole('deny'); + + $result = $this->resolveWithMocks([$override], [$role], 'file.read'); + $this->assertFalse($result, 'Role DENY should deny when server override is unset'); + } + + /** Role ALLOW should allow when no server override is set */ + public function test_role_allow_allows_when_no_server_override(): void + { + $override = $this->makeOverride('unset'); + $role = $this->makeRole('allow'); + + $result = $this->resolveWithMocks([$override], [$role], 'file.read'); + $this->assertTrue($result, 'Role ALLOW should allow when server override is unset'); + } + + /** Default deny when nothing is set */ + public function test_default_deny_when_nothing_set(): void + { + $override = $this->makeOverride('unset'); + $result = $this->resolveWithMocks([$override], [], 'file.read'); + $this->assertFalse($result, 'Should default to deny when no rules set'); + } + + /** Higher priority role wins on conflict */ + public function test_higher_priority_role_wins(): void + { + $lowRole = $this->makeRole('deny', priority: 10); + $highRole = $this->makeRole('allow', priority: 100); + $override = $this->makeOverride('unset'); + + // High priority role should win (sorted desc by priority) + $result = $this->resolveWithMocks([$override], [$lowRole, $highRole], 'file.read'); + $this->assertTrue($result, 'Higher priority role (allow) should win over lower priority (deny)'); + } + + /** No privilege escalation β€” deny still blocks even if other role allows */ + public function test_explicit_deny_in_any_role_blocks(): void + { + // If ANY role in resolution order says deny first (by priority), it should deny + $denyRole = $this->makeRole('deny', priority: 50); + $allowRole = $this->makeRole('allow', priority: 10); + $override = $this->makeOverride('unset'); + + $result = $this->resolveWithMocks([$override], [$denyRole, $allowRole], 'file.read'); + $this->assertFalse($result, 'Deny in higher-priority role should block allow in lower role'); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private function resolveWithMocks(array $overrides, array $roles, string $permission): bool + { + // Use a test double of the resolver that accepts mock data + $resolver = new class extends PermissionResolver { + public array $mockOverrides = []; + public array $mockRoles = []; + + public function resolve(int $userId, int $serverId, string $permission): bool + { + $overridesCollection = collect($this->mockOverrides)->keyBy('permission'); + $rolesCollection = collect($this->mockRoles)->sortByDesc('priority')->values(); + [$result] = $this->resolveWithSourcePublic($permission, $overridesCollection, $rolesCollection); + return $result; + } + + public function resolveWithSourcePublic(string $perm, $overrides, $roles): array + { + return $this->resolveWithSource($perm, $overrides, $roles); + } + }; + + // This tests the private method via reflection for correctness + $ref = new \ReflectionMethod(PermissionResolver::class, 'resolveWithSource'); + $ref->setAccessible(true); + + $overridesCol = collect($overrides)->keyBy('permission'); + $rolesCol = collect($roles)->sortByDesc('priority')->values(); + + [$result] = $ref->invoke($this->resolver, $permission, $overridesCol, $rolesCol); + return $result; + } + + private function makeOverride(string $value): object + { + return new class ($value) { + public string $permission = 'file.read'; + public string $value; + public function __construct(string $v) { $this->value = $v; } + public function isAllow(): bool { return $this->value === 'allow'; } + public function isDeny(): bool { return $this->value === 'deny'; } + public function isUnset(): bool { return $this->value === 'unset'; } + }; + } + + private function makeRole(string $value, int $priority = 50): object + { + return new class ($value, $priority) { + public int $priority; + public string $name = 'Test Role'; + private string $val; + public function __construct(string $v, int $p) { $this->val = $v; $this->priority = $p; } + public function getPermission(string $p): ?string { return $this->val === 'unset' ? null : $this->val; } + }; + } +} diff --git a/tests/Addons/Unit/RevisionStorageTest.php b/tests/Addons/Unit/RevisionStorageTest.php new file mode 100644 index 0000000..6f88241 --- /dev/null +++ b/tests/Addons/Unit/RevisionStorageTest.php @@ -0,0 +1,134 @@ + + * @license MIT + */ + +namespace Tests\Addons\Unit; + +use PHPUnit\Framework\TestCase; +use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionStorageService; +use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\ExclusionMatcher; + +class RevisionStorageTest extends TestCase +{ + private RevisionStorageService $storage; + + protected function setUp(): void + { + parent::setUp(); + $this->storage = new RevisionStorageService(); + } + + // ── Path Traversal Prevention ───────────────────────────────────────────── + + public function test_rejects_absolute_path(): void + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); + $this->storage->guardFilePath('/etc/passwd'); + } + + public function test_rejects_dotdot_traversal(): void + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); + $this->storage->guardFilePath('../../../etc/passwd'); + } + + public function test_rejects_encoded_traversal(): void + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); + $this->storage->guardFilePath('./../../secret'); + } + + public function test_accepts_valid_relative_path(): void + { + $result = $this->storage->guardFilePath('config/server.properties'); + $this->assertEquals('config/server.properties', $result); + } + + // ── Binary Detection ────────────────────────────────────────────────────── + + public function test_detects_binary_content(): void + { + $binary = "MZ\x90\x00\x03\x00\x00\x00"; // PE header + $this->assertTrue($this->storage->isBinary($binary)); + } + + public function test_accepts_text_content(): void + { + $text = "# Config file\nserver-name=My Server\nmax-players=20\n"; + $this->assertFalse($this->storage->isBinary($text)); + } + + // ── Content Hashing ─────────────────────────────────────────────────────── + + public function test_content_hash_is_consistent(): void + { + $content = "hello world"; + $hash1 = $this->storage->contentHash($content); + $hash2 = $this->storage->contentHash($content); + $this->assertEquals($hash1, $hash2); + } + + public function test_different_content_produces_different_hash(): void + { + $h1 = $this->storage->contentHash("content A"); + $h2 = $this->storage->contentHash("content B"); + $this->assertNotEquals($h1, $h2); + } + + // ── Path Hash is Server-Scoped ───────────────────────────────────────────── + + public function test_path_hash_is_server_scoped(): void + { + $hash1 = $this->storage->pathHash(1, 'config.yml'); + $hash2 = $this->storage->pathHash(2, 'config.yml'); + $this->assertNotEquals($hash1, $hash2, 'Same file on different servers must have different path hashes'); + } +} + +/** + * Exclusion Matcher Tests + */ +class ExclusionMatcherTest extends TestCase +{ + private function matcher(array $patterns = []): ExclusionMatcher + { + // Override config for testing + config(['advanced-admin.revisions.excluded_patterns' => $patterns ?: [ + '.env', '*.key', '*.pem', '*.p12', 'secrets.yml', '*.sql', '*.bak', + ]]); + return new ExclusionMatcher(); + } + + public function test_excludes_dotenv(): void + { + $this->assertTrue($this->matcher()->isExcluded('.env')); + } + + public function test_excludes_key_files(): void + { + $this->assertTrue($this->matcher()->isExcluded('private.key')); + $this->assertTrue($this->matcher()->isExcluded('server.pem')); + } + + public function test_excludes_sql_dumps(): void + { + $this->assertTrue($this->matcher()->isExcluded('backup.sql')); + } + + public function test_allows_normal_config(): void + { + $this->assertFalse($this->matcher()->isExcluded('server.properties')); + $this->assertFalse($this->matcher()->isExcluded('config/spigot.yml')); + } + + public function test_excludes_sensitive_keyword_in_name(): void + { + $this->assertTrue($this->matcher()->isExcluded('my-secret-key.txt')); + $this->assertTrue($this->matcher()->isExcluded('password-store.txt')); + } +} diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..804a1c6 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# ============================================================================= +# Pterodactyl Advanced Admin Addons β€” Uninstaller +# Author : Ball Studios +# Usage : bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/uninstall.sh) +# ============================================================================= + +set -euo pipefail +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +step() { echo -e "\n${BOLD}β–Ά $*${NC}"; } + +PANEL_DIR="${1:-/var/www/pterodactyl}" +PHP_BIN="$(command -v php)" + +echo -e "\n${BOLD}${RED}Pterodactyl Advanced Admin Addons β€” Uninstaller${NC}" +warn "This will remove ALL addon data from the database!" +read -rp "Type 'uninstall' to confirm: " CONFIRM +[[ "$CONFIRM" == "uninstall" ]] || { info "Aborted."; exit 0; } + +[[ $EUID -eq 0 ]] || error "Must be run as root." +[[ -f "${PANEL_DIR}/artisan" ]] || error "Panel not found at ${PANEL_DIR}." + +step "Rolling back database migrations..." +cd "$PANEL_DIR" +$PHP_BIN artisan migrate:rollback --path=database/migrations/addon --force +success "Migrations rolled back." + +step "Rolling back patches..." +$PHP_BIN artisan addon:patch rollback 2>/dev/null || warn "Manual patch rollback required β€” see patches/PATCHES.md" + +step "Removing addon files..." +rm -rf "${PANEL_DIR}/app/Addons/AdvancedAdmin" +rm -f "${PANEL_DIR}/config/advanced-admin.php" +rm -rf "${PANEL_DIR}/config/addon-commands" +rm -f "${PANEL_DIR}/routes/addon-client.php" +rm -f "${PANEL_DIR}/routes/addon-application.php" +rm -rf "${PANEL_DIR}/resources/scripts/addons/advanced-admin" +rm -rf "${PANEL_DIR}/database/migrations/addon" +rm -rf "${PANEL_DIR}/storage/app/addons/revisions" +success "Files removed." + +step "Rebuilding frontend..." +export NODE_OPTIONS="${NODE_OPTIONS:---openssl-legacy-provider}" +yarn --cwd "$PANEL_DIR" build:production +success "Frontend rebuilt." + +step "Clearing caches..." +$PHP_BIN artisan optimize:clear && $PHP_BIN artisan optimize +success "Uninstall complete." diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..17a3b66 --- /dev/null +++ b/update.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# ============================================================================= +# Pterodactyl Advanced Admin Addons β€” Updater +# Author : Ball Studios +# Usage : bash <(curl -fsSL https://git.balls.studio/BallStudios/pterodactyl_addon/raw/branch/main/update.sh) +# ============================================================================= + +set -euo pipefail +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +step() { echo -e "\n${BOLD}β–Ά $*${NC}"; } + +PANEL_DIR="${1:-/var/www/pterodactyl}" +REPO_URL="https://git.balls.studio/BallStudios/pterodactyl_addon" +TMP_DIR="$(mktemp -d)" +BACKUP_DIR="${PANEL_DIR}/.addon_update_backup_$(date +%Y%m%d_%H%M%S)" +PHP_BIN="$(command -v php)" + +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +echo -e "\n${BOLD}${CYAN}Pterodactyl Advanced Admin Addons β€” Updater${NC}\n" + +[[ $EUID -eq 0 ]] || error "Must be run as root." +[[ -f "${PANEL_DIR}/artisan" ]] || error "Panel not found at ${PANEL_DIR}." + +step "Backing up current addon files..." +mkdir -p "$BACKUP_DIR" +cp -r "${PANEL_DIR}/app/Addons/AdvancedAdmin" "$BACKUP_DIR/" 2>/dev/null || true +cp -r "${PANEL_DIR}/resources/scripts/addons/advanced-admin" "$BACKUP_DIR/" 2>/dev/null || true +success "Backup β†’ ${BACKUP_DIR}" + +step "Rolling back old patches..." +cd "$PANEL_DIR" +$PHP_BIN artisan addon:patch rollback 2>/dev/null || warn "Patch rollback command not available β€” skipping." + +step "Pulling latest addon..." +git clone --depth=1 "$REPO_URL" "$TMP_DIR/addon" +cd "$TMP_DIR/addon" + +cp -r app/Addons "${PANEL_DIR}/app/" +cp config/advanced-admin.php "${PANEL_DIR}/config/" +cp -r config/addon-commands "${PANEL_DIR}/config/" +cp -r database/migrations/addon/. "${PANEL_DIR}/database/migrations/addon/" +cp routes/addon-client.php "${PANEL_DIR}/routes/" +cp routes/addon-application.php "${PANEL_DIR}/routes/" +cp -r resources/scripts/addons/advanced-admin "${PANEL_DIR}/resources/scripts/addons/" +success "Files updated." + +step "Applying patches..." +bash "${PANEL_DIR}/app/Addons/AdvancedAdmin/../../../install.sh" --patches-only 2>/dev/null || \ + warn "Re-run install.sh manually if patches need reapplying." + +step "Running new migrations..." +cd "$PANEL_DIR" +$PHP_BIN artisan migrate --path=database/migrations/addon --force +success "Migrations done." + +step "Rebuilding frontend..." +export NODE_OPTIONS="${NODE_OPTIONS:---openssl-legacy-provider}" +yarn install --frozen-lockfile && yarn build:production +success "Frontend rebuilt." + +step "Clearing caches..." +$PHP_BIN artisan optimize:clear && $PHP_BIN artisan optimize +$PHP_BIN artisan queue:restart +success "Update complete!"