File transfer
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
83
README.md
83
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
|
||||
|
||||
119
app/Addons/AdvancedAdmin/AddonServiceProvider.php
Normal file
119
app/Addons/AdvancedAdmin/AddonServiceProvider.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Pterodactyl Advanced Admin Addons — Service Provider
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* File Revision Controller — client API
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Network Metrics Controller — client API
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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',
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Addons/AdvancedAdmin/Http/Resources/RoleResource.php
Normal file
31
app/Addons/AdvancedAdmin/Http/Resources/RoleResource.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Collect Network Metrics Job — runs every 10 seconds per node.
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Addons/AdvancedAdmin/Models/FileRevision.php
Normal file
51
app/Addons/AdvancedAdmin/Models/FileRevision.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
}
|
||||
20
app/Addons/AdvancedAdmin/Models/FileRevisionStorageUsage.php
Normal file
20
app/Addons/AdvancedAdmin/Models/FileRevisionStorageUsage.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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'];
|
||||
}
|
||||
73
app/Addons/AdvancedAdmin/Models/Role.php
Normal file
73
app/Addons/AdvancedAdmin/Models/Role.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
52
app/Addons/AdvancedAdmin/Models/RoleAuditLog.php
Normal file
52
app/Addons/AdvancedAdmin/Models/RoleAuditLog.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
23
app/Addons/AdvancedAdmin/Models/RolePermission.php
Normal file
23
app/Addons/AdvancedAdmin/Models/RolePermission.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
}
|
||||
21
app/Addons/AdvancedAdmin/Models/ServerPermissionOverride.php
Normal file
21
app/Addons/AdvancedAdmin/Models/ServerPermissionOverride.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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'; }
|
||||
}
|
||||
33
app/Addons/AdvancedAdmin/Models/ServerRoleAssignment.php
Normal file
33
app/Addons/AdvancedAdmin/Models/ServerRoleAssignment.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
}
|
||||
188
app/Addons/AdvancedAdmin/Services/Console/AutocompleteEngine.php
Normal file
188
app/Addons/AdvancedAdmin/Services/Console/AutocompleteEngine.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Autocomplete Engine — resolves command suggestions from stored definitions + player cache.
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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<int, array{command: string, description: string, syntax: string, dangerous: bool, plugin: string}>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Docker Stats Collector — queries Wings REST API for per-server network stats.
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
}
|
||||
116
app/Addons/AdvancedAdmin/Services/Revisions/DiffService.php
Normal file
116
app/Addons/AdvancedAdmin/Services/Revisions/DiffService.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Diff Service — generates unified diffs between two text contents.
|
||||
*
|
||||
* Uses SebastianBergmann\Diff if available, falls back to simple line diff.
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Exclusion Matcher — determines if a file path matches any exclusion glob pattern.
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
166
app/Addons/AdvancedAdmin/Services/Revisions/RevisionService.php
Normal file
166
app/Addons/AdvancedAdmin/Services/Revisions/RevisionService.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Revision Service — orchestrates creating, restoring, and pruning revisions.
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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}")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* File Revision Storage Service
|
||||
* Handles compressed storage, path traversal prevention, and binary detection.
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
123
app/Addons/AdvancedAdmin/Services/Roles/PermissionResolver.php
Normal file
123
app/Addons/AdvancedAdmin/Services/Roles/PermissionResolver.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Permission Resolver — Core of the Role-Based Permission System
|
||||
*
|
||||
* Resolution order (first match wins):
|
||||
* 1. Server-level explicit DENY → false
|
||||
* 2. Server-level explicit ALLOW → true
|
||||
* 3. Role DENY (highest priority role first) → false
|
||||
* 4. Role ALLOW → true
|
||||
* 5. Default → false
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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<string, array{value: bool, source: string}>
|
||||
*/
|
||||
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'];
|
||||
}
|
||||
}
|
||||
201
app/Addons/AdvancedAdmin/Services/Roles/RoleService.php
Normal file
201
app/Addons/AdvancedAdmin/Services/Roles/RoleService.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
210
config/addon-commands/vanilla.json
Normal file
210
config/addon-commands/vanilla.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
192
config/advanced-admin.php
Normal file
192
config/advanced-admin.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Pterodactyl Advanced Admin Addons — Configuration
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Migration: Network Metrics Tables
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Migration: Role-Based Permissions Tables
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Migration: File Revision History Tables
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Migration: Console Autocomplete Tables
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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');
|
||||
}
|
||||
};
|
||||
109
database/seeders/DefaultRolesSeeder.php
Normal file
109
database/seeders/DefaultRolesSeeder.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Default Roles Seeder
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
60
docs/CONFIG.md
Normal file
60
docs/CONFIG.md
Normal file
@@ -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 |
|
||||
107
docs/INSTALL.md
Normal file
107
docs/INSTALL.md
Normal file
@@ -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_<timestamp>/`
|
||||
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).
|
||||
97
docs/SECURITY.md
Normal file
97
docs/SECURITY.md
Normal file
@@ -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.
|
||||
274
install.sh
Normal file
274
install.sh
Normal file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Pterodactyl Advanced Admin Addons — Auto Installer
|
||||
# Author : Ball Studios <https://git.balls.studio>
|
||||
# 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 = \" <Route path=\\\"/server/:id/network\\\" element={<NetworkTab />} />\"
|
||||
# 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(\"</Routes>\", route_line + \"\n </Routes>\", 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 ""
|
||||
91
patches/PATCHES.md
Normal file
91
patches/PATCHES.md
Normal file
@@ -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';
|
||||
// ...
|
||||
</Routes>
|
||||
+ <Route path="/server/:id/network" element={<NetworkTab />} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Autocomplete Dropdown
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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 (
|
||||
<ul className='addon-autocomplete-dropdown' role='listbox'>
|
||||
{suggestions.map((sug, i) => (
|
||||
<li
|
||||
key={sug.command}
|
||||
className={`addon-autocomplete-item ${i === selectedIndex ? 'active' : ''} ${sug.dangerous ? 'danger' : ''}`}
|
||||
role='option'
|
||||
aria-selected={i === selectedIndex}
|
||||
onClick={() => onSelect(sug)}
|
||||
onMouseDown={e => e.preventDefault()} // prevent blur
|
||||
>
|
||||
<span className='addon-ac-command'>/{sug.command}</span>
|
||||
{sug.dangerous && <span className='addon-ac-danger-badge'>⚠ Danger</span>}
|
||||
<span className='addon-ac-desc'>{sug.description}</span>
|
||||
<span className='addon-ac-plugin'>{sug.plugin}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
158
resources/scripts/addons/advanced-admin/console/CommandInput.tsx
Normal file
158
resources/scripts/addons/advanced-admin/console/CommandInput.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Console Command Input with Autocomplete
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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<Suggestion[]>([]);
|
||||
const [selected, setSelected]= useState(-1);
|
||||
const [showDanger, setDanger]= useState<Suggestion | null>(null);
|
||||
const [activeSug, setActiveSug] = useState<Suggestion | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className='addon-console-input-wrap'>
|
||||
<div className='addon-console-input-row'>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
className='addon-console-input'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Enter command…'
|
||||
disabled={disabled}
|
||||
autoComplete='off'
|
||||
spellCheck={false}
|
||||
aria-label='Console command input'
|
||||
aria-autocomplete='list'
|
||||
/>
|
||||
<button
|
||||
className='addon-console-send'
|
||||
onClick={submit}
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label='Send command'
|
||||
>▶</button>
|
||||
</div>
|
||||
|
||||
{/* Syntax helper */}
|
||||
{activeSug && <SyntaxHelper suggestion={activeSug} />}
|
||||
|
||||
{/* Dropdown */}
|
||||
{suggestions.length > 0 && (
|
||||
<AutocompleteDropdown
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selected}
|
||||
onSelect={applySelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Danger modal */}
|
||||
{showDanger && (
|
||||
<DangerCommandWarning
|
||||
suggestion={showDanger}
|
||||
onConfirm={() => send(value.trim())}
|
||||
onCancel={() => { setDanger(null); inputRef.current?.focus(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Danger Command Warning Modal
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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 (
|
||||
<div className='addon-modal-overlay' role='dialog' aria-modal='true' aria-label='Danger warning'>
|
||||
<div className='addon-modal addon-modal-danger'>
|
||||
<div className='addon-modal-icon'>⚠️</div>
|
||||
<h3 className='addon-modal-title'>Potentially Dangerous Command</h3>
|
||||
<p className='addon-modal-body'>
|
||||
<code>/{suggestion.command}</code> may have destructive effects.
|
||||
{suggestion.danger_reason && <><br />{suggestion.danger_reason}</>}
|
||||
</p>
|
||||
<div className='addon-modal-actions'>
|
||||
<button className='addon-btn addon-btn-secondary' onClick={onCancel}>Cancel</button>
|
||||
<button className='addon-btn addon-btn-danger' onClick={onConfirm}>Send Anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Syntax Helper — shows command signature below the input
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Suggestion { syntax: string; examples: string[]; plugin: string; }
|
||||
interface Props { suggestion: Suggestion; }
|
||||
|
||||
export default function SyntaxHelper({ suggestion }: Props) {
|
||||
return (
|
||||
<div className='addon-syntax-helper'>
|
||||
<code className='addon-syntax'>{suggestion.syntax}</code>
|
||||
{suggestion.examples.length > 0 && (
|
||||
<span className='addon-syntax-example'>e.g. {suggestion.examples[0]}</span>
|
||||
)}
|
||||
{suggestion.plugin && (
|
||||
<span className='addon-syntax-plugin'>[{suggestion.plugin}]</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
resources/scripts/addons/advanced-admin/network/FlowGraph.tsx
Normal file
103
resources/scripts/addons/advanced-admin/network/FlowGraph.tsx
Normal file
@@ -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 <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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<SVGSVGElement>(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<string>();
|
||||
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<string, { x: number; y: number }> = {};
|
||||
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 <div className='addon-empty'>No inter-container flows detected in the last 24h.</div>;
|
||||
}
|
||||
|
||||
return <svg ref={svgRef} className='addon-flow-graph' width='100%' height='280' />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Live Traffic Cards
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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 (
|
||||
<div className='addon-stat-grid'>
|
||||
<div className='addon-stat-card inbound'>
|
||||
<div className='addon-stat-label'>⬇ Inbound</div>
|
||||
<div className='addon-stat-value'>{live ? formatBytes(live.rx_bytes) : '—'}</div>
|
||||
</div>
|
||||
<div className='addon-stat-card outbound'>
|
||||
<div className='addon-stat-label'>⬆ Outbound</div>
|
||||
<div className='addon-stat-value'>{live ? formatBytes(live.tx_bytes) : '—'}</div>
|
||||
</div>
|
||||
<div className='addon-stat-card total'>
|
||||
<div className='addon-stat-label'>⇅ Total</div>
|
||||
<div className='addon-stat-value'>{live ? formatBytes(live.rx_bytes + live.tx_bytes) : '—'}</div>
|
||||
</div>
|
||||
<div className='addon-stat-card updated'>
|
||||
<div className='addon-stat-label'>🕐 Last Update</div>
|
||||
<div className='addon-stat-value'>{live ? new Date(live.recorded_at).toLocaleTimeString() : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
resources/scripts/addons/advanced-admin/network/NetworkTab.tsx
Normal file
125
resources/scripts/addons/advanced-admin/network/NetworkTab.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Network Traffic Dashboard — Main Tab
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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<LiveStats | null>(null);
|
||||
const [history, setHistory] = useState<HistoryPoint[]>([]);
|
||||
const [ports, setPorts] = useState<any[]>([]);
|
||||
const [flows, setFlows] = useState<any[]>([]);
|
||||
const [resolution, setRes] = useState<Resolution>('5m');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <div className='addon-loading'>Loading network data…</div>;
|
||||
if (error) return <div className='addon-error'>{error}</div>;
|
||||
|
||||
return (
|
||||
<div className='addon-network'>
|
||||
<h2 className='addon-section-title'>🌐 Network Traffic</h2>
|
||||
|
||||
{/* Live cards */}
|
||||
<LiveTrafficCards live={live} />
|
||||
|
||||
{/* Historical chart */}
|
||||
<div className='addon-card'>
|
||||
<div className='addon-card-header'>
|
||||
<span>Bandwidth Over Time</span>
|
||||
<div className='addon-resolution-tabs'>
|
||||
{(['5m', '1h', '1d'] as Resolution[]).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
className={`addon-tab ${resolution === r ? 'active' : ''}`}
|
||||
onClick={() => setRes(r)}
|
||||
>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TrafficChart data={history} />
|
||||
</div>
|
||||
|
||||
{/* Port breakdown */}
|
||||
<div className='addon-card'>
|
||||
<div className='addon-card-header'>Top Ports</div>
|
||||
<PortTable ports={ports} />
|
||||
</div>
|
||||
|
||||
{/* Flow graph */}
|
||||
<div className='addon-card'>
|
||||
<div className='addon-card-header'>Container Traffic Flow</div>
|
||||
<FlowGraph flows={flows} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Port Table — top ports by traffic
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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 <div className='addon-empty'>Port-level data requires iptables collection (disabled by default).</div>;
|
||||
}
|
||||
return (
|
||||
<table className='addon-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Port</th><th>Proto</th><th>⬇ Inbound</th><th>⬆ Outbound</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ports.map(p => (
|
||||
<tr key={`${p.port}-${p.protocol}`}>
|
||||
<td>{p.port}</td>
|
||||
<td><span className={`addon-badge ${p.protocol}`}>{p.protocol.toUpperCase()}</span></td>
|
||||
<td>{fmt(p.rx_bytes)}</td>
|
||||
<td>{fmt(p.tx_bytes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Traffic Chart — Recharts line chart for bandwidth over time
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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 <div className='addon-empty'>No historical data available yet.</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<ResponsiveContainer width='100%' height={260}>
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray='3 3' stroke='rgba(255,255,255,0.08)' />
|
||||
<XAxis dataKey='time' tick={{ fill: '#9ca3af', fontSize: 11 }} tickLine={false} />
|
||||
<YAxis tickFormatter={formatTick} tick={{ fill: '#9ca3af', fontSize: 11 }} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1f2937', border: 'none', borderRadius: 8 }}
|
||||
formatter={(v: number, name: string) => [
|
||||
`${(v / 1024 / 1024).toFixed(2)} MB`,
|
||||
name === 'rx' ? '⬇ Inbound' : '⬆ Outbound',
|
||||
]}
|
||||
/>
|
||||
<Legend formatter={(v) => v === 'rx' ? '⬇ Inbound' : '⬆ Outbound'} />
|
||||
<Line type='monotone' dataKey='rx' stroke='#34d399' dot={false} strokeWidth={2} />
|
||||
<Line type='monotone' dataKey='tx' stroke='#60a5fa' dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Diff Viewer — unified diff with syntax coloring
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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<DiffLine[]>([]);
|
||||
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 (
|
||||
<div className='addon-modal-overlay' onClick={onClose} role='dialog' aria-modal='true'>
|
||||
<div className='addon-modal addon-modal-wide' onClick={e => e.stopPropagation()}>
|
||||
<div className='addon-modal-header'>
|
||||
<span>Diff — Revision #{revision.revision_number}</span>
|
||||
<button className='addon-modal-close' onClick={onClose} aria-label='Close'>✕</button>
|
||||
</div>
|
||||
{loading && <div className='addon-loading'>Loading diff…</div>}
|
||||
{!loading && binary && <div className='addon-empty'>Binary file — diff not available.</div>}
|
||||
{!loading && !binary && (
|
||||
<pre className='addon-diff'>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`addon-diff-line addon-diff-${line.type}`}
|
||||
>
|
||||
<span className='addon-diff-lnum'>
|
||||
{line.line_old ?? ' '} {line.line_new ?? ' '}
|
||||
</span>
|
||||
<span className='addon-diff-sign'>
|
||||
{line.type === 'added' ? '+' : line.type === 'removed' ? '−' : ' '}
|
||||
</span>
|
||||
{line.content}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* File History Button — injected into the file editor toolbar
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
className='addon-btn addon-btn-sm addon-btn-history'
|
||||
onClick={() => setOpen(true)}
|
||||
title='View file revision history'
|
||||
aria-label='View file revision history'
|
||||
>
|
||||
🕐 History
|
||||
</button>
|
||||
{open && serverUuid && (
|
||||
<RevisionSidebar
|
||||
serverUuid={serverUuid}
|
||||
filePath={filePath}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Restore Modal — confirmation before overwriting current file
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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<string | null>(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 (
|
||||
<div className='addon-modal-overlay' role='dialog' aria-modal='true'>
|
||||
<div className='addon-modal'>
|
||||
<div className='addon-modal-icon'>🔄</div>
|
||||
<h3 className='addon-modal-title'>Restore Revision #{revision.revision_number}?</h3>
|
||||
<p className='addon-modal-body'>
|
||||
This will overwrite the current file with revision #{revision.revision_number}.<br />
|
||||
<em>{revision.change_summary}</em>
|
||||
</p>
|
||||
{error && <p className='addon-error'>{error}</p>}
|
||||
<div className='addon-modal-actions'>
|
||||
<button className='addon-btn addon-btn-secondary' onClick={onClose} disabled={loading}>Cancel</button>
|
||||
<button className='addon-btn addon-btn-primary' onClick={handleRestore} disabled={loading}>
|
||||
{loading ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Revision Sidebar — slide-in panel listing file revisions
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
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<Revision[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewing, setViewing] = useState<Revision | null>(null);
|
||||
const [restoring, setRestoring] = useState<Revision | null>(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 (
|
||||
<div className='addon-sidebar-overlay' onClick={onClose} aria-modal='true' role='dialog'>
|
||||
<div className='addon-sidebar' onClick={e => e.stopPropagation()}>
|
||||
<div className='addon-sidebar-header'>
|
||||
<span>🕐 File History — <code>{filePath}</code></span>
|
||||
<button className='addon-sidebar-close' onClick={onClose} aria-label='Close'>✕</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className='addon-loading'>Loading revisions…</div>}
|
||||
|
||||
{!loading && revisions.length === 0 && (
|
||||
<div className='addon-empty'>No revisions recorded for this file yet.</div>
|
||||
)}
|
||||
|
||||
<ul className='addon-revision-list'>
|
||||
{revisions.map(rev => (
|
||||
<li key={rev.id} className='addon-revision-item'>
|
||||
<div className='addon-revision-meta'>
|
||||
<span className='addon-revision-num'>#{rev.revision_number}</span>
|
||||
<span className='addon-revision-author'>{rev.author?.username ?? 'Unknown'}</span>
|
||||
<span className='addon-revision-date'>{new Date(rev.created_at).toLocaleString()}</span>
|
||||
<span className='addon-revision-size'>{(rev.size_bytes / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
<p className='addon-revision-summary'>{rev.change_summary}</p>
|
||||
<div className='addon-revision-actions'>
|
||||
<button className='addon-btn addon-btn-sm' onClick={() => setViewing(rev)}>View Diff</button>
|
||||
<button className='addon-btn addon-btn-sm addon-btn-primary' onClick={() => setRestoring(rev)}>Restore</button>
|
||||
<a
|
||||
className='addon-btn addon-btn-sm'
|
||||
href={`/api/client/servers/${serverUuid}/addons/files/revisions/${rev.id}/download`}
|
||||
download
|
||||
>Download</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{viewing && (
|
||||
<DiffViewer
|
||||
serverUuid={serverUuid}
|
||||
revision={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{restoring && (
|
||||
<RestoreModal
|
||||
serverUuid={serverUuid}
|
||||
revision={restoring}
|
||||
onClose={() => setRestoring(null)}
|
||||
onRestored={() => { setRestoring(null); onClose(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
routes/addon-application.php
Normal file
51
routes/addon-application.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Addon API Routes — Application API (Admin only)
|
||||
* Loaded by AddonServiceProvider
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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']);
|
||||
|
||||
});
|
||||
61
routes/addon-client.php
Normal file
61
routes/addon-client.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Addon API Routes — Client API
|
||||
* Loaded by AddonServiceProvider
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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']);
|
||||
});
|
||||
|
||||
});
|
||||
155
tests/Addons/Unit/PermissionResolverTest.php
Normal file
155
tests/Addons/Unit/PermissionResolverTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Permission Resolver Unit Tests
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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; }
|
||||
};
|
||||
}
|
||||
}
|
||||
134
tests/Addons/Unit/RevisionStorageTest.php
Normal file
134
tests/Addons/Unit/RevisionStorageTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Revision Storage Security Unit Tests
|
||||
*
|
||||
* @author Ball Studios <https://git.balls.studio>
|
||||
* @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'));
|
||||
}
|
||||
}
|
||||
53
uninstall.sh
Normal file
53
uninstall.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Pterodactyl Advanced Admin Addons — Uninstaller
|
||||
# Author : Ball Studios <https://git.balls.studio>
|
||||
# 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."
|
||||
70
update.sh
Normal file
70
update.sh
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Pterodactyl Advanced Admin Addons — Updater
|
||||
# Author : Ball Studios <https://git.balls.studio>
|
||||
# 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!"
|
||||
Reference in New Issue
Block a user