File transfer

This commit is contained in:
2026-06-11 20:33:44 -05:00
parent 2cb1d58264
commit b362357bcb
58 changed files with 5096 additions and 4 deletions

21
LICENSE Normal file
View 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.

View File

@@ -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

View 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');
}
}

View File

@@ -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.');
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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.');
}
}

View File

@@ -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',
];
}
}

View File

@@ -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',
];
}
}

View 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(),
];
}
}

View File

@@ -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);
}
}
}

View 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');
}
}

View 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'];
}

View 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;
}
}

View 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(),
]);
}
}

View 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');
}
}

View 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'; }
}

View 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');
}
}

View 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();
}
}

View File

@@ -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');
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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}")]
);
}
}

View File

@@ -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.');
}
}
}

View 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'];
}
}

View 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;
}
}

View 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
View 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',
],
],
];

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View 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
View 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
View 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
View 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 (09999)
### 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
View 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
View 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
```

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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' />;
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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
View 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']);
});
});

View 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; }
};
}
}

View 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
View 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
View 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!"