mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-05 19:51:59 +02:00
PLEASE BE EVERYTHING :SOB: IT'S BEEN HOOOUORS
This commit is contained in:
@@ -5,4 +5,5 @@ vagrant/
|
||||
nix/
|
||||
flake.nix
|
||||
flake.lock
|
||||
|
||||
var/
|
||||
srv/
|
||||
|
||||
36
.gitattributes
vendored
36
.gitattributes
vendored
@@ -1 +1,35 @@
|
||||
* text eol=lf
|
||||
* text eol=lf
|
||||
|
||||
# Images
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.bmp binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.tiff binary
|
||||
*.tif binary
|
||||
*.svg binary
|
||||
*.avif binary
|
||||
*.heic binary
|
||||
*.heif binary
|
||||
|
||||
# Audio
|
||||
*.mp3 binary
|
||||
*.wav binary
|
||||
*.ogg binary
|
||||
*.flac binary
|
||||
*.aac binary
|
||||
*.m4a binary
|
||||
*.wma binary
|
||||
|
||||
# Video
|
||||
*.mp4 binary
|
||||
*.mov binary
|
||||
*.avi binary
|
||||
*.mkv binary
|
||||
*.webm binary
|
||||
*.flv binary
|
||||
*.wmv binary
|
||||
*.m4v binary
|
||||
4
.github/docker/default_ssl.conf
vendored
4
.github/docker/default_ssl.conf
vendored
@@ -8,7 +8,8 @@ server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name <domain>;
|
||||
|
||||
root /app/public;
|
||||
@@ -61,7 +62,6 @@ server {
|
||||
fastcgi_connect_timeout 300;
|
||||
fastcgi_send_timeout 300;
|
||||
fastcgi_read_timeout 300;
|
||||
include /etc/nginx/fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\.ht {
|
||||
|
||||
99
.github/docker/entrypoint-post.sh
vendored
99
.github/docker/entrypoint-post.sh
vendored
@@ -1,99 +0,0 @@
|
||||
#!/bin/ash -e
|
||||
cd /app
|
||||
|
||||
# Directory and log setup
|
||||
mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php7/ \
|
||||
&& chmod 777 /var/log/panel/logs/ \
|
||||
&& ln -s /app/storage/logs/ /var/log/panel/
|
||||
|
||||
# Check mounted /app/var directory
|
||||
if [ ! -d /app/var ]; then
|
||||
echo "You must mount the /app/var directory to the container."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# .env file handling
|
||||
if [ ! -f /app/var/.env ]; then
|
||||
echo "Creating .env file."
|
||||
touch /app/var/.env
|
||||
fi
|
||||
|
||||
rm -f /app/.env
|
||||
ln -s /app/var/.env /app/
|
||||
|
||||
# Environment configuration
|
||||
(
|
||||
source /app/.env
|
||||
|
||||
if [ -z "$APP_KEY" ]; then
|
||||
echo "Generating APP_KEY"
|
||||
echo "APP_KEY=" >> /app/.env
|
||||
APP_ENVIRONMENT_ONLY=true php artisan key:generate
|
||||
fi
|
||||
|
||||
if [ -z "$HASHIDS_LENGTH" ]; then
|
||||
echo "Defaulting HASHIDS_LENGTH to 8"
|
||||
echo "HASHIDS_LENGTH=8" >> /app/.env
|
||||
fi
|
||||
|
||||
if [ -z "$HASHIDS_SALT" ]; then
|
||||
echo "Generating HASHIDS_SALT"
|
||||
HASHIDS_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 20 | head -n 1)
|
||||
echo "HASHIDS_SALT=$HASHIDS_SALT" >> /app/.env
|
||||
fi
|
||||
)
|
||||
|
||||
# SSL configuration
|
||||
echo "Checking if https is required."
|
||||
if [ -f /etc/nginx/http.d/panel.conf ]; then
|
||||
echo "Using nginx config already in place."
|
||||
if [ $LE_EMAIL ]; then
|
||||
echo "Checking for cert update"
|
||||
certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n
|
||||
fi
|
||||
else
|
||||
if [ -z $LE_EMAIL ]; then
|
||||
echo "Using http config."
|
||||
cp .github/docker/default.conf /etc/nginx/http.d/panel.conf
|
||||
else
|
||||
echo "Configuring SSL"
|
||||
cp .github/docker/default_ssl.conf /etc/nginx/http.d/panel.conf
|
||||
sed -i "s|<domain>|$(echo $APP_URL | sed 's~http[s]*://~~g')|g" /etc/nginx/http.d/panel.conf
|
||||
certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n
|
||||
fi
|
||||
rm -rf /etc/nginx/http.d/default.conf
|
||||
fi
|
||||
|
||||
# Database configuration
|
||||
if [[ -z $DB_PORT ]]; then
|
||||
DB_PORT=3306
|
||||
echo "DB_PORT not specified, defaulting to 3306"
|
||||
fi
|
||||
|
||||
# Wait for database
|
||||
echo "Checking database status."
|
||||
until nc -z -v -w30 $DB_HOST $DB_PORT
|
||||
do
|
||||
echo "Waiting for database connection..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Database migration with seeding protection
|
||||
echo "Checking database migrations."
|
||||
if ! php artisan migrate:status | grep -q "No migrations found"; then
|
||||
# Only run migrations if needed
|
||||
php artisan migrate --force
|
||||
|
||||
# Check if we need to seed (only if migrations ran)
|
||||
if php artisan migrate:status | grep -q "Ran"; then
|
||||
echo "Running database seed if needed."
|
||||
php artisan db:seed --force
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start services
|
||||
echo "Starting cron jobs."
|
||||
crond -L /var/log/crond -l 5
|
||||
|
||||
echo "Starting supervisord."
|
||||
exec "$@"
|
||||
88
.github/docker/entrypoint.sh
vendored
88
.github/docker/entrypoint.sh
vendored
@@ -5,6 +5,10 @@ mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php
|
||||
&& chmod 777 /var/log/panel/logs/ \
|
||||
&& ln -s /app/storage/logs/ /var/log/panel/
|
||||
|
||||
# Ensure proper permissions for Laravel storage directories
|
||||
mkdir -p /app/storage/logs /app/storage/framework/cache /app/storage/framework/sessions /app/storage/framework/views \
|
||||
&& chmod -R 777 /app/storage/
|
||||
|
||||
# Check that user has mounted the /app/var directory
|
||||
if [ ! -d /app/var ]; then
|
||||
echo "You must mount the /app/var directory to the container."
|
||||
@@ -91,12 +95,90 @@ do
|
||||
done
|
||||
|
||||
## make sure the db is set up
|
||||
echo -e "Migrating and Seeding D.B"
|
||||
php artisan migrate --seed --force
|
||||
echo -e "Migrating Database"
|
||||
php artisan migrate --force
|
||||
|
||||
if [ "$SKIP_SEED" != "True" ]; then
|
||||
echo -e "Seeding database"
|
||||
php artisan migrate --seed --force
|
||||
else
|
||||
echo -e "Skipping database seeding (SKIP_SEED=True)"
|
||||
fi
|
||||
|
||||
# Setup development environment if specified
|
||||
(
|
||||
source /app/.env
|
||||
if [ "$PYRODACTYL_DOCKER_DEV" = "true" ] && [ "$DEV_SETUP" != "true" ]; then
|
||||
echo -e "\e[42mDevelopment environment detected, setting up development resources...\e[0m"
|
||||
|
||||
# Create a developer user
|
||||
php artisan p:user:make -n --email dev@pyro.host --username dev --name-first Developer --name-last User --password password
|
||||
mariadb -u root -h database -p"$DB_ROOT_PASSWORD" --ssl=0 -e "USE panel; UPDATE users SET root_admin = 1;" # workaround because --admin is broken
|
||||
|
||||
# Make a location and node for the panel
|
||||
php artisan p:location:make -n --short local --long Local
|
||||
php artisan p:node:make -n --name local --description "Development Node" --locationId 1 --fqdn localhost --internal-fqdn $WINGS_INTERNAL_IP --public 1 --scheme http --proxy 0 --maxMemory 1024 --maxDisk 10240 --overallocateMemory 0 --overallocateDisk 0
|
||||
|
||||
echo "Adding dummy allocations..."
|
||||
mariadb -u root -h database -p"$DB_ROOT_PASSWORD" --ssl=0 -e "USE panel; INSERT INTO allocations (node_id, ip, port) VALUES (1, '0.0.0.0', 25565), (1, '0.0.0.0', 25566), (1, '0.0.0.0', 25567);"
|
||||
|
||||
echo "Creating database user..."
|
||||
mariadb -u root -h database -p"$DB_ROOT_PASSWORD" --ssl=0 -e "CREATE USER 'pterodactyluser'@'%' IDENTIFIED BY 'somepassword'; GRANT ALL PRIVILEGES ON *.* TO 'pterodactyluser'@'%' WITH GRANT OPTION;"
|
||||
|
||||
# Configure node
|
||||
export WINGS_CONFIG=/etc/pterodactyl/config.yml
|
||||
mkdir -p $(dirname $WINGS_CONFIG)
|
||||
echo "Fetching and modifying Wings configuration file..."
|
||||
CONFIG=$(php artisan p:node:configuration 1)
|
||||
|
||||
# Allow all origins for CORS
|
||||
CONFIG=$(printf "%s\nallowed_origins: ['*']" "$CONFIG")
|
||||
|
||||
# Update Wings configuration paths if WINGS_DIR is set
|
||||
if [ -z "$WINGS_DIR" ]; then
|
||||
echo "WINGS_DIR is not set, using default paths."
|
||||
else
|
||||
echo "Updating Wings configuration paths to '$WINGS_DIR'..."
|
||||
|
||||
# add system section if it doesn't exist
|
||||
if ! echo "$CONFIG" | grep -q "^system:"; then
|
||||
CONFIG=$(printf "%s\nsystem:" "$CONFIG")
|
||||
fi
|
||||
|
||||
update_config() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
|
||||
# update existing key or add new one
|
||||
if echo "$CONFIG" | grep -q "^ $key:"; then
|
||||
CONFIG=$(echo "$CONFIG" | sed "s|^ $key:.*| $key: $value|")
|
||||
else
|
||||
CONFIG=$(echo "$CONFIG" | sed "/^system:/a\\ $key: $value")
|
||||
fi
|
||||
}
|
||||
|
||||
update_config "root_directory" "$WINGS_DIR/srv/wings/"
|
||||
update_config "log_directory" "$WINGS_DIR/srv/wings/logs/"
|
||||
update_config "data" "$WINGS_DIR/srv/wings/volumes"
|
||||
update_config "archive_directory" "$WINGS_DIR/srv/wings/archives"
|
||||
update_config "backup_directory" "$WINGS_DIR/srv/wings/backups"
|
||||
update_config "tmp_directory" "$WINGS_DIR/srv/wings/tmp/"
|
||||
fi
|
||||
|
||||
echo "Saving Wings configuration file to '$WINGS_CONFIG'..."
|
||||
echo "$CONFIG" > $WINGS_CONFIG
|
||||
|
||||
# Mark setup as complete
|
||||
echo "DEV_SETUP=true" >> /app/.env
|
||||
echo "Development setup complete."
|
||||
elif [ "$DEV_SETUP" = "true" ]; then
|
||||
echo "Skipping development setup, already completed."
|
||||
fi
|
||||
)
|
||||
|
||||
## start cronjobs for the queue
|
||||
echo -e "Starting cron jobs."
|
||||
crond -L /var/log/crond -l 5
|
||||
|
||||
echo -e "Starting supervisord."
|
||||
exec "$@"
|
||||
exec "$@"
|
||||
|
||||
101
.github/workflows/build-and-release.yaml
vendored
Normal file
101
.github/workflows/build-and-release.yaml
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
name: Docker Build and Release Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-dev:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, 'Update version to')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push canary image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:canary
|
||||
ghcr.io/${{ github.repository }}:dev
|
||||
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push release image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:main
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
update-version:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
steps:
|
||||
- name: Checkout main branch at release commit
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.target_commitish }}
|
||||
|
||||
- name: Update version in config/app.php
|
||||
run: sed -i "s/'version' => 'canary'/'version' => '${{ github.event.release.tag_name }}'/g" config/app.php
|
||||
|
||||
- name: Commit and push to release branch
|
||||
run: |
|
||||
git config user.name "pyrodactyl-ci"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B release
|
||||
git add config/app.php
|
||||
git commit -m "Update version to ${{ github.event.release.tag_name }} in config/app.php"
|
||||
git push origin release --force
|
||||
|
||||
72
.github/workflows/docker.yaml
vendored
72
.github/workflows/docker.yaml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Release']
|
||||
types:
|
||||
- completed
|
||||
push:
|
||||
branches:
|
||||
- release/**
|
||||
- main
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
push:
|
||||
name: Push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/pyrohost/pyrodactyl
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: |
|
||||
type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'published' && !github.event.release.prerelease }}
|
||||
type=ref,event=tag
|
||||
type=raw,value=${{ github.event.workflow_run.head_branch || github.ref_name }},enable=${{ startsWith(github.ref, 'refs/heads/release/') }}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update version
|
||||
if: "github.event_name == 'release' && github.event.action == 'published'"
|
||||
env:
|
||||
REF: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
sed -i "s/ 'version' => 'canary',/ 'version' => '${REF:1}',/" config/app.php
|
||||
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -18,7 +18,10 @@ _ide_helper_models.php
|
||||
public/assets/manifest.json
|
||||
|
||||
# For local development with docker
|
||||
#docker-compose.yml
|
||||
docker-compose.yml
|
||||
local_docker
|
||||
/srv
|
||||
/var
|
||||
|
||||
# for image related files
|
||||
misc
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
# Contributing
|
||||
|
||||
pyro.host does not accept Pull Requests (PRs) _for new functionality_ from users that are not currently employed
|
||||
or affiliated by Pyro. It has become overwhelming to try and give the proper time and attention that such
|
||||
complicated PRs tend to require — and deserve. As a result, it is in the project's best interest to limit the scope
|
||||
of work on new functionality to work done within the Pyro team.
|
||||
|
||||
PRs that address existing _bugs_ with a corresponding issue opened in our issue tracker will continue to be accepted
|
||||
and reviewed. Their scope is often significantly more targeted, and simply improving upon existing and well defined
|
||||
logic.
|
||||
|
||||
### Responsible Disclosure
|
||||
|
||||
This is a fairly in-depth project and makes use of a lot of parts. We strive to keep everything as secure as possible
|
||||
@@ -20,7 +9,7 @@ publicly disclose whatever issue you have found. We understand how frustrating i
|
||||
no one will respond to you. This holds us to a standard of providing prompt attention to any issues that arise and
|
||||
keeping this community safe.
|
||||
|
||||
If you've found what you believe is a security issue please email `team@pyro.host`. Please check
|
||||
If you've found what you believe is a security issue please email `naterfute@pyro.host`. Please check
|
||||
[SECURITY.md](/SECURITY.md) for additional details.
|
||||
|
||||
### Contact Us
|
||||
|
||||
122
Dockerfile
122
Dockerfile
@@ -1,60 +1,98 @@
|
||||
# TODO: Refactor Docker with stricter permissions & modernized tooling
|
||||
|
||||
# Stage 0:
|
||||
# Build the frontend
|
||||
# Build the frontend (only if not in dev mode)
|
||||
FROM --platform=$TARGETOS/$TARGETARCH node:lts-alpine AS frontend
|
||||
ARG DEV=false
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
RUN apk add --no-cache --update git \
|
||||
&& npm install -g turbo \
|
||||
&& npm ci \
|
||||
&& npm run ship \
|
||||
&& apk del git
|
||||
RUN if [ "$DEV" = "false" ]; then \
|
||||
apk add --no-cache git \
|
||||
&& npm install -g corepack@latest turbo \
|
||||
&& corepack enable \
|
||||
&& echo "Building frontend"; \
|
||||
fi
|
||||
COPY pnpm-lock.yaml package.json ./
|
||||
RUN if [ "$DEV" = "false" ]; then \
|
||||
pnpm fetch \
|
||||
&& echo "Fetched dependencies"; \
|
||||
fi
|
||||
COPY . .
|
||||
RUN if [ "$DEV" = "false" ]; then \
|
||||
pnpm install --frozen-lockfile \
|
||||
&& pnpm run ship; \
|
||||
fi
|
||||
|
||||
# Stage 1:
|
||||
# Build the actual container with all of the needed PHP dependencies that will run the application.
|
||||
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
|
||||
# Build the actual container with all of the needed PHP dependencies that will run the application
|
||||
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine AS php
|
||||
ARG DEV=false
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
COPY --from=frontend /app/public/assets ./public/assets
|
||||
COPY --from=frontend /app/public/build ./public/build
|
||||
|
||||
RUN apk add --no-cache --update \
|
||||
ca-certificates \
|
||||
dcron \
|
||||
curl \
|
||||
git \
|
||||
supervisor \
|
||||
tar \
|
||||
unzip \
|
||||
nginx \
|
||||
libpng-dev \
|
||||
libxml2-dev \
|
||||
libzip-dev \
|
||||
postgresql-dev \
|
||||
certbot \
|
||||
certbot-nginx \
|
||||
mysql-client \
|
||||
# Build-time deps & PHP extensions
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
libpng-dev libxml2-dev libzip-dev postgresql-dev \
|
||||
&& docker-php-ext-configure zip \
|
||||
&& docker-php-ext-install bcmath gd pdo pdo_mysql pdo_pgsql zip \
|
||||
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
||||
&& cp .env.example .env \
|
||||
&& mkdir -p bootstrap/cache/ storage/logs storage/framework/sessions storage/framework/views storage/framework/cache \
|
||||
&& chmod -R 777 bootstrap storage \
|
||||
&& composer install --no-dev --optimize-autoloader \
|
||||
&& rm -rf .env bootstrap/cache/*.php \
|
||||
&& mkdir -p /app/storage/logs/ \
|
||||
&& chown -R nginx:nginx .
|
||||
&& apk del .build-deps \
|
||||
&& apk add --no-cache \
|
||||
libpng libxml2 libzip libpq
|
||||
|
||||
# Runtime packages
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates curl git supervisor nginx dcron \
|
||||
tar unzip certbot certbot-nginx mysql-client \
|
||||
&& ln -s /bin/ash /bin/bash
|
||||
|
||||
# Copy frontend build
|
||||
COPY . ./
|
||||
RUN if [ "$DEV" = "false" ]; then \
|
||||
echo "Copying frontend build"; \
|
||||
else \
|
||||
mkdir -p public/assets public/build; \
|
||||
fi
|
||||
COPY --from=frontend /app/public/assets public/assets
|
||||
COPY --from=frontend /app/public/build public/build
|
||||
|
||||
# Fetch & install Composer packages
|
||||
COPY composer.json composer.lock ./
|
||||
RUN curl -sS https://getcomposer.org/installer \
|
||||
| php -- --install-dir=/usr/local/bin --filename=composer \
|
||||
&& composer install --no-dev --optimize-autoloader
|
||||
|
||||
# Clean up image for dev environment
|
||||
# This is because we share local files with the container
|
||||
RUN if [ "$DEV" = "true" ]; then \
|
||||
echo "Cleaning up"; \
|
||||
find . \
|
||||
-mindepth 1 \
|
||||
\( -path './vendor*' \) -prune \
|
||||
-o \
|
||||
-exec rm -rf -- {} \; \
|
||||
>/dev/null 2>&1; \
|
||||
fi; \
|
||||
exit 0
|
||||
|
||||
# Env, directories, permissions
|
||||
RUN mkdir -p bootstrap/cache storage/logs storage/framework/sessions storage/framework/views storage/framework/cache; \
|
||||
rm -rf bootstrap/cache/*.php; \
|
||||
chown -R nginx:nginx .; \
|
||||
chmod -R 777 bootstrap storage; \
|
||||
cp .env.example .env || true;
|
||||
|
||||
# Cron jobs & NGINX tweaks
|
||||
RUN rm /usr/local/etc/php-fpm.conf \
|
||||
&& echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
|
||||
&& echo "0 23 * * * certbot renew --nginx --quiet" >> /var/spool/cron/crontabs/root \
|
||||
&& sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \
|
||||
&& { \
|
||||
echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1"; \
|
||||
echo "0 23 * * * certbot renew --nginx --quiet"; \
|
||||
} > /var/spool/cron/crontabs/root \
|
||||
&& sed -i 's/ssl_session_cache/#ssl_session_cache/' /etc/nginx/nginx.conf \
|
||||
&& mkdir -p /var/run/php /var/run/nginx
|
||||
|
||||
# Configs
|
||||
COPY --chown=nginx:nginx .github/docker/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY --chown=nginx:nginx .github/docker/www.conf /usr/local/etc/php-fpm.conf
|
||||
COPY --chown=nginx:nginx .github/docker/supervisord.conf /etc/supervisord.conf
|
||||
COPY --chown=nginx:nginx .github/docker/www.conf /usr/local/etc/php-fpm.conf
|
||||
COPY --chown=nginx:nginx .github/docker/supervisord.conf /etc/supervisord.conf
|
||||
|
||||
RUN ln -s /bin/ash /bin/bash
|
||||
|
||||
EXPOSE 80 443
|
||||
ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ]
|
||||
|
||||
26
README.md
26
README.md
@@ -21,6 +21,9 @@
|
||||
> [!WARNING]
|
||||
> Pyrodactyl is under development and pre-release. Some UI elements may appear broken, and there might be some bugs.
|
||||
|
||||
> [!NOTE]
|
||||
> Please read our documentation at [https://pyrodactyl.dev](https://pyrodactyl.dev) before installing.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> For Pyrodactyl-specific issues, please use [Pyrodactyl GitHub Discussions](https://github.com/pyrohost/pyrodactyl/discussions) or the [Pyrodactyl Discord](https://discord.gg/UhuYKKK2uM?utm_source=githubreadme&utm_medium=readme&utm_campaign=OSSLAUNCH&utm_id=OSSLAUNCH) instead of Pterodactyl or Pelican support channels.
|
||||
|
||||
@@ -30,30 +33,25 @@ Pyrodactyl is the Pterodactyl-based game server management panel that focuses on
|
||||
|
||||
## Changes from vanilla Pterodactyl
|
||||
|
||||
- **Smaller bundle sizes:** Pyrodactyl is built using Vite, and significant re-architecting of the application means Pyrodactyl's initial download size is over **[170 times smaller than leading Pterodactyl forks, and Pelican](https://i.imgur.com/tKWLHhR.png)**
|
||||
- **Faster build times:** Pyrodactyl completes builds in milliseconds with the power of Turbo. Cold builds with zero cache finish in **under 7 seconds**.
|
||||
- **Faster loading times:** Pyrodactyl's load times are, on average, **[over 16 times faster](https://i.imgur.com/28XxmMi.png)** than other closed-source Pterodactyl forks and Pelican. Smarter code splitting and chunking means that pages you visit in the panel only load necessary resources on demand. Better caching means that everything is simply _snappy_.
|
||||
- **More secure:** Pyrodactyl's modern architecture means **most severe and easily exploitable CVEs simply do not exist**. We have also implemented SRI and integrity checks for production builds.
|
||||
- **More accessible:** Pyro believes that gaming should be easily available for everyone. Pyrodactyl builds with the latest Web accessibility guidelines in mind. Pyrodactyl is **entirely keyboard-navigable, even context menus.**, and screen-readers are easily compatible.
|
||||
- **More approachable:** Pyrodactyl's friendly, approachable interface means that anyone can confidently run a game server.
|
||||
- **Smaller bundle sizes:** Pyrodactyl is built using Vite, and significant design changes mean Pyrodactyl's initial download size is over **[170 times smaller than leading Pterodactyl forks, including Pelican](https://i.imgur.com/tKWLHhR.png)**.
|
||||
- **Faster build times:** Pyrodactyl completes builds in milliseconds with the power of Turbo. Cold builds with zero cache finish in **under 7 seconds**.
|
||||
- **Faster loading times:** Pyrodactyl's load times are, on average, **[over 16 times faster](https://i.imgur.com/28XxmMi.png)** than other closed-source Pterodactyl forks and Pelican. Smarter code splitting and chunking means that pages you visit in the panel only load necessary resources on demand. Better caching means that everything is simply _snappy_.
|
||||
- **More secure:** Pyrodactyl's modern architecture means **most severe and easily exploitable CVEs simply do not exist**. We have also implemented SRI and integrity checks for production builds.
|
||||
- **More accessible:** Pyro believes that gaming should be easily available for everyone. Pyrodactyl builds with the latest Web accessibility guidelines in mind. Pyrodactyl is **entirely keyboard-navigable, even context menus**, and screen-readers are easily compatible.
|
||||
- **More approachable:** Pyrodactyl's friendly, approachable interface means that anyone can confidently run a game server.
|
||||
|
||||
[]
|
||||

|
||||
|
||||
## Installing Pyrodactyl
|
||||
|
||||
See our [Installation](https://pyrodactyl.dev/docs/installation) wiki page on how to get started.
|
||||
See our [Installation](https://pyrodactyl.dev/docs/installation) docs page on how to get started.
|
||||
|
||||
> [!NOTE]
|
||||
> Windows is currently only supported for development purposes.
|
||||
|
||||
## Local Development
|
||||
|
||||
See our development pages on how to get started:
|
||||
|
||||
- [Local Dev on Windows (Vagrant)](<https://github.com/pyrohost/pyrodactyl/wiki/Local-Dev-on-Windows-(Vagrant)>)
|
||||
- [Local Dev on Linux (Vagrant)](<https://github.com/pyrohost/pyrodactyl/wiki/Local-Dev-on-Linux-(Vagrant)>)
|
||||
- [Local Dev on Linux (Nix)](<https://github.com/pyrohost/pyrodactyl/wiki/Local-Dev-on-Linux-(Nix)>)
|
||||
- Nix is recommended if you can't get Vagrant working
|
||||
Pyrodactyl has various effortless ways of starting up a ready-to-use, fully-featured development environment. See our [Local Development](https://pyrodactyl.dev/docs/local-development) documentation for more information.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
130
Vagrantfile
vendored
130
Vagrantfile
vendored
@@ -1,52 +1,98 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
BOX_DEFAULT = "ubuntu/jammy64"
|
||||
BOX_LIBVIRT = "generic/ubuntu2204"
|
||||
|
||||
RAM = (ENV["VM_RAM"] || "8192").to_i
|
||||
CPUS = (ENV["VM_CPUS"] || "8").to_i
|
||||
|
||||
SPECIAL_PORTS = [
|
||||
3000, # pyrodactyl web ui
|
||||
3306, # database
|
||||
8080, # phpmyadmin
|
||||
8025, # mailpit web ui
|
||||
9000, # minio api
|
||||
9001 # minio console
|
||||
]
|
||||
|
||||
TEST_PORTS = (25500..25600)
|
||||
|
||||
FORWARDED_PORTS = SPECIAL_PORTS + TEST_PORTS.to_a
|
||||
|
||||
Vagrant.configure("2") do |config|
|
||||
config.vm.box = "almalinux/9"
|
||||
config.vm.network "forwarded_port", guest: 3000, host: 3000, host_ip: "localhost"
|
||||
config.vm.network "forwarded_port", guest: 8080, host: 8080, host_ip: "localhost"
|
||||
# Base box and hostname
|
||||
config.vm.box = BOX_DEFAULT
|
||||
config.vm.hostname = "pyrodactyl-dev"
|
||||
|
||||
# you need enough RAM for packages to install properly
|
||||
config.vm.provider "virtualbox" do |vb|
|
||||
vb.memory = "4096"
|
||||
vb.cpus = "4"
|
||||
vb.cpuexecutioncap = 95
|
||||
vb.customize ["modifyvm", :id, "--nictype1", "virtio"]
|
||||
vb.customize ["modifyvm", :id, "--nictype2", "virtio"]
|
||||
vb.customize ["storagectl", :id, "--name", "IDE Controller", "--remove"]
|
||||
vb.customize ["storagectl", :id, "--name", "SATA Controller", "--add", "sata"]
|
||||
vb.customize ["modifyvm", :id, "--boot1", "disk"]
|
||||
vb.customize ["modifyvm", :id, "--nic1", "nat"]
|
||||
# Forwarded ports
|
||||
FORWARDED_PORTS.each do |p|
|
||||
config.vm.network "forwarded_port",
|
||||
guest: p,
|
||||
host: p,
|
||||
host_ip: "127.0.0.1",
|
||||
auto_correct: false
|
||||
end
|
||||
|
||||
end
|
||||
config.vm.provider "vmware_desktop" do |v|
|
||||
v.vmx["memsize"] = "4096"
|
||||
v.vmx["numvcpus"] = "4"
|
||||
v.vmx["tools.upgrade.policy"] = "manual"
|
||||
v.vmx["RemoteDisplay.vnc.enabled"] = "FALSE"
|
||||
v.vmx["vhv.enable"] = "FALSE"
|
||||
v.vmx["ethernet0.connectionType"] = "nat"
|
||||
v.vmx["ethernet0.wakeOnPacketTx"] = "TRUE"
|
||||
v.vmx["ethernet0.addressType"] = "generated"
|
||||
end
|
||||
# Libvirt provider
|
||||
config.vm.provider "libvirt" do |libvirt|
|
||||
libvirt.memory = 8192
|
||||
libvirt.cpus = 4
|
||||
config.vm.network "public_network", dev: "bridge0"
|
||||
# VirtualBox provider settings
|
||||
config.vm.provider "virtualbox" do |vb|
|
||||
vb.name = "pyrodactyl-dev"
|
||||
vb.memory = RAM
|
||||
vb.cpus = CPUS
|
||||
vb.gui = false
|
||||
|
||||
end
|
||||
# setup the synced folder and provision the VM
|
||||
config.vm.synced_folder ".", "/var/www/pterodactyl"
|
||||
# type: "virtualbox"
|
||||
# nfs_version: 4
|
||||
vb.customize ["modifyvm", :id, "--cpuexecutioncap", "95"]
|
||||
vb.customize ["modifyvm", :id, "--nic1", "nat"]
|
||||
vb.customize ["modifyvm", :id, "--nictype1", "virtio"]
|
||||
end
|
||||
|
||||
config.vm.provision "shell", path: "vagrant/provision.sh"
|
||||
# VMware provider settings
|
||||
config.vm.provider "vmware_desktop" do |v|
|
||||
v.vmx["memsize"] = RAM.to_s
|
||||
v.vmx["numvcpus"] = CPUS.to_s
|
||||
v.vmx["tools.upgrade.policy"] = "manual"
|
||||
v.vmx["RemoteDisplay.vnc.enabled"] = "FALSE"
|
||||
v.vmx["vhv.enable"] = "FALSE"
|
||||
v.vmx["ethernet0.connectionType"] = "nat"
|
||||
v.vmx["ethernet0.wakeOnPacketTx"] = "TRUE"
|
||||
v.vmx["ethernet0.addressType"] = "generated"
|
||||
end
|
||||
|
||||
config.vm.hostname = "pyrodactyl-dev"
|
||||
# Libvirt provider settings
|
||||
config.vm.provider "libvirt" do |lv, override|
|
||||
override.vm.box = BOX_LIBVIRT
|
||||
lv.memory = RAM
|
||||
lv.cpus = CPUS
|
||||
end
|
||||
|
||||
# Synced folder configuration
|
||||
if Vagrant::Util::Platform.windows?
|
||||
# Use VirtualBox shared folders on Windows (no authentication)
|
||||
config.vm.synced_folder ".", "/var/www/pterodactyl",
|
||||
type: "virtualbox",
|
||||
owner: "vagrant",
|
||||
group: "vagrant",
|
||||
mount_options: ["dmode=755", "fmode=644"]
|
||||
else
|
||||
# Use NFS on Linux/macOS
|
||||
config.vm.synced_folder ".", "/var/www/pterodactyl",
|
||||
type: "nfs",
|
||||
nfs_version: 4,
|
||||
nfs_udp: false,
|
||||
mount_options: ["rw", "vers=4", "tcp", "fsc", "rsize=1048576", "wsize=1048576"]
|
||||
end
|
||||
|
||||
config.vm.post_up_message = "Pterodactyl is up and running at http://localhost:3000. Login with username: dev@pyro.host, password: 'password'."
|
||||
# Provisioning script
|
||||
config.vm.provision "shell",
|
||||
path: "vagrant/provision.sh",
|
||||
keep_color: true,
|
||||
privileged: true
|
||||
|
||||
# allocated testing ports
|
||||
config.vm.network "forwarded_port", guest: 25565, host: 25565, host_ip: "localhost"
|
||||
config.vm.network "forwarded_port", guest: 25566, host: 25566, host_ip: "localhost"
|
||||
config.vm.network "forwarded_port", guest: 25567, host: 25567, host_ip: "localhost"
|
||||
# Helpful post-up message
|
||||
config.vm.post_up_message = <<~MSG
|
||||
Pyrodactyl is up and running at http://localhost:3000
|
||||
Login with:
|
||||
username: dev@pyro.host
|
||||
password: password
|
||||
MSG
|
||||
end
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Console\Commands\Maintenance;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Pterodactyl\Services\Backups\DeleteBackupService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DeleteOrphanedBackupsCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:maintenance:delete-orphaned-backups {--dry-run : Show what would be deleted without actually deleting}';
|
||||
|
||||
protected $description = 'Delete backups that reference non-existent servers (orphaned backups), including soft-deleted backups.';
|
||||
|
||||
/**
|
||||
* DeleteOrphanedBackupsCommand constructor.
|
||||
*/
|
||||
public function __construct(private DeleteBackupService $deleteBackupService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
|
||||
// Find backups that reference non-existent servers including
|
||||
// soft-deleted backups since they might be orphaned too
|
||||
$orphanedBackups = Backup::withTrashed()
|
||||
->whereDoesntHave('server')
|
||||
->get();
|
||||
|
||||
if ($orphanedBackups->isEmpty()) {
|
||||
$this->info('No orphaned backups found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$count = $orphanedBackups->count();
|
||||
$totalSize = $orphanedBackups->sum('bytes');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn("Found {$count} orphaned backup(s) that would be deleted (Total size: {$this->formatBytes($totalSize)}):");
|
||||
|
||||
$this->table(
|
||||
['ID', 'UUID', 'Name', 'Server ID', 'Disk', 'Size', 'Status', 'Created At'],
|
||||
$orphanedBackups->map(function (Backup $backup) {
|
||||
return [
|
||||
$backup->id,
|
||||
$backup->uuid,
|
||||
$backup->name,
|
||||
$backup->server_id,
|
||||
$backup->disk,
|
||||
$this->formatBytes($backup->bytes),
|
||||
$backup->trashed() ? 'Soft Deleted' : 'Active',
|
||||
$backup->created_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
|
||||
$this->info('Run without --dry-run to actually delete these backups.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->confirm("Are you sure you want to delete {$count} orphaned backup(s) ({$this->formatBytes($totalSize)})? This action cannot be undone.")) {
|
||||
$this->info('Operation cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn("Deleting {$count} orphaned backup(s) ({$this->formatBytes($totalSize)})...");
|
||||
|
||||
$deletedCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
foreach ($orphanedBackups as $backup) {
|
||||
try {
|
||||
// If backup is already soft-deleted, force delete it completely
|
||||
if ($backup->trashed()) {
|
||||
$backup->forceDelete();
|
||||
$deletedCount++;
|
||||
$this->info("Force deleted soft-deleted backup: {$backup->uuid} ({$backup->name}) - {$this->formatBytes($backup->bytes)}");
|
||||
} else {
|
||||
// Use the service to properly delete from storage and database
|
||||
$this->deleteBackupService->handle($backup);
|
||||
$deletedCount++;
|
||||
$this->info("Deleted backup: {$backup->uuid} ({$backup->name}) - {$this->formatBytes($backup->bytes)}");
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
$failedCount++;
|
||||
$this->error("Failed to delete backup {$backup->uuid}: {$exception->getMessage()}");
|
||||
|
||||
// If we can't delete from storage, at least remove the database record
|
||||
try {
|
||||
if ($backup->trashed()) {
|
||||
$backup->forceDelete();
|
||||
$this->warn("Force deleted soft-deleted backup {$backup->uuid} (storage deletion failed)");
|
||||
} else {
|
||||
$backup->delete();
|
||||
$this->warn("Removed database record for backup {$backup->uuid} (storage deletion failed)");
|
||||
}
|
||||
} catch (\Exception $dbException) {
|
||||
$this->error("Failed to remove database record for backup {$backup->uuid}: {$dbException->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Cleanup completed. Deleted: {$deletedCount}, Failed: {$failedCount}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into human readable format.
|
||||
*/
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$base = 1024;
|
||||
$exponent = floor(log($bytes) / log($base));
|
||||
$exponent = min($exponent, count($units) - 1);
|
||||
|
||||
$value = $bytes / pow($base, $exponent);
|
||||
$unit = $units[$exponent];
|
||||
|
||||
return sprintf('%.2f %s', $value, $unit);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class MakeNodeCommand extends Command
|
||||
{--description= : A description to identify the node.}
|
||||
{--locationId= : A valid locationId.}
|
||||
{--fqdn= : The domain name (e.g node.example.com) to be used for connecting to the daemon. An IP address may only be used if you are not using SSL for this node.}
|
||||
{--internal-fqdn= : Internal domain name for panel-to-Wings communication (optional).}
|
||||
{--public= : Should the node be public or private? (public=1 / private=0).}
|
||||
{--scheme= : Which scheme should be used? (Enable SSL=https / Disable SSL=http).}
|
||||
{--proxy= : Is the daemon behind a proxy? (Yes=1 / No=0).}
|
||||
@@ -51,6 +52,7 @@ class MakeNodeCommand extends Command
|
||||
'https'
|
||||
);
|
||||
$data['fqdn'] = $this->option('fqdn') ?? $this->ask('Enter a domain name (e.g node.example.com) to be used for connecting to the daemon. An IP address may only be used if you are not using SSL for this node');
|
||||
$data['internal_fqdn'] = $this->option('internal-fqdn') ?? $this->ask('Enter internal FQDN for panel-to-Wings communication (leave blank to use public FQDN)', '');
|
||||
$data['public'] = $this->option('public') ?? $this->confirm('Should this node be public? As a note, setting a node to private you will be denying the ability to auto-deploy to this node.', true);
|
||||
$data['behind_proxy'] = $this->option('proxy') ?? $this->confirm('Is your FQDN behind a proxy?');
|
||||
$data['maintenance_mode'] = $this->option('maintenance') ?? $this->confirm('Should maintenance mode be enabled?');
|
||||
|
||||
41
app/Contracts/Captcha/CaptchaProviderInterface.php
Normal file
41
app/Contracts/Captcha/CaptchaProviderInterface.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Contracts\Captcha;
|
||||
|
||||
interface CaptchaProviderInterface
|
||||
{
|
||||
/**
|
||||
* Get the HTML widget for the captcha.
|
||||
*/
|
||||
public function getWidget(string $form): string;
|
||||
|
||||
/**
|
||||
* Verify a captcha response.
|
||||
*/
|
||||
public function verify(string $response, ?string $remoteIp = null): bool;
|
||||
|
||||
/**
|
||||
* Get the JavaScript includes needed for this captcha provider.
|
||||
*/
|
||||
public function getScriptIncludes(): array;
|
||||
|
||||
/**
|
||||
* Get the provider name.
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Get the site key for frontend use.
|
||||
*/
|
||||
public function getSiteKey(): string;
|
||||
|
||||
/**
|
||||
* Check if the provider is properly configured.
|
||||
*/
|
||||
public function isConfigured(): bool;
|
||||
|
||||
/**
|
||||
* Get the response field name for this provider.
|
||||
*/
|
||||
public function getResponseFieldName(): string;
|
||||
}
|
||||
91
app/Contracts/Dns/DnsProviderInterface.php
Normal file
91
app/Contracts/Dns/DnsProviderInterface.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Contracts\Dns;
|
||||
|
||||
interface DnsProviderInterface
|
||||
{
|
||||
/**
|
||||
* Test the connection to the DNS provider.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Dns\DnsProviderException
|
||||
*/
|
||||
public function testConnection(): bool;
|
||||
|
||||
/**
|
||||
* Create a DNS record.
|
||||
*
|
||||
* @param string $domain The domain name
|
||||
* @param string $name The record name (subdomain or full name for SRV)
|
||||
* @param string $type The record type (A, SRV, CNAME, etc.)
|
||||
* @param string|array $content The record content (IP for A, structured data for SRV)
|
||||
* @param int $ttl Time to live in seconds
|
||||
* @return string The created record ID
|
||||
* @throws \Pterodactyl\Exceptions\Dns\DnsProviderException
|
||||
*/
|
||||
public function createRecord(string $domain, string $name, string $type, $content, int $ttl = 300): string;
|
||||
|
||||
/**
|
||||
* Update a DNS record.
|
||||
*
|
||||
* @param string $domain The domain name
|
||||
* @param string $recordId The record ID to update
|
||||
* @param string|array $content The new record content
|
||||
* @param int|null $ttl Optional new TTL
|
||||
* @return bool True if successful
|
||||
* @throws \Pterodactyl\Exceptions\Dns\DnsProviderException
|
||||
*/
|
||||
public function updateRecord(string $domain, string $recordId, $content, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Delete a DNS record.
|
||||
*
|
||||
* @param string $domain The domain name
|
||||
* @param string $recordId The record ID to delete
|
||||
* @throws \Pterodactyl\Exceptions\Dns\DnsProviderException
|
||||
*/
|
||||
public function deleteRecord(string $domain, string $recordId): void;
|
||||
|
||||
/**
|
||||
* Get a specific DNS record.
|
||||
*
|
||||
* @param string $domain The domain name
|
||||
* @param string $recordId The record ID
|
||||
* @return array The DNS record data
|
||||
* @throws \Pterodactyl\Exceptions\Dns\DnsProviderException
|
||||
*/
|
||||
public function getRecord(string $domain, string $recordId): array;
|
||||
|
||||
/**
|
||||
* List existing DNS records for a domain.
|
||||
*
|
||||
* @param string $domain The domain name
|
||||
* @param string|null $name Filter by record name (optional)
|
||||
* @param string|null $type Filter by record type (optional)
|
||||
* @return array Array of DNS records
|
||||
* @throws \Pterodactyl\Exceptions\Dns\DnsProviderException
|
||||
*/
|
||||
public function listRecords(string $domain, ?string $name = null, ?string $type = null): array;
|
||||
|
||||
/**
|
||||
* Get the configuration schema for this provider.
|
||||
*
|
||||
* @return array Array of configuration fields and their requirements
|
||||
*/
|
||||
public function getConfigurationSchema(): array;
|
||||
|
||||
/**
|
||||
* Validate the provider configuration.
|
||||
*
|
||||
* @param array $config The configuration to validate
|
||||
* @return bool True if valid
|
||||
* @throws \Pterodactyl\Exceptions\Dns\DnsProviderException
|
||||
*/
|
||||
public function validateConfiguration(array $config): bool;
|
||||
|
||||
/**
|
||||
* Get the supported record types for this provider.
|
||||
*
|
||||
* @return array Array of supported DNS record types
|
||||
*/
|
||||
public function getSupportedRecordTypes(): array;
|
||||
}
|
||||
@@ -9,133 +9,133 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface RepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Return an identifier or Model object to be used by the repository.
|
||||
*/
|
||||
public function model(): string;
|
||||
/**
|
||||
* Return an identifier or Model object to be used by the repository.
|
||||
*/
|
||||
public function model(): string;
|
||||
|
||||
/**
|
||||
* Return the model being used for this repository instance.
|
||||
*/
|
||||
public function getModel(): Model;
|
||||
/**
|
||||
* Return the model being used for this repository instance.
|
||||
*/
|
||||
public function getModel(): Model;
|
||||
|
||||
/**
|
||||
* Returns an instance of a query builder.
|
||||
*/
|
||||
public function getBuilder(): Builder;
|
||||
/**
|
||||
* Returns an instance of a query builder.
|
||||
*/
|
||||
public function getBuilder(): Builder;
|
||||
|
||||
/**
|
||||
* Returns the columns to be selected or returned by the query.
|
||||
*/
|
||||
public function getColumns(): array;
|
||||
/**
|
||||
* Returns the columns to be selected or returned by the query.
|
||||
*/
|
||||
public function getColumns(): array;
|
||||
|
||||
/**
|
||||
* An array of columns to filter the response by.
|
||||
*/
|
||||
public function setColumns(array|string $columns = ['*']): self;
|
||||
/**
|
||||
* An array of columns to filter the response by.
|
||||
*/
|
||||
public function setColumns(array|string $columns = ['*']): self;
|
||||
|
||||
/**
|
||||
* Stop repository update functions from returning a fresh
|
||||
* model when changes are committed.
|
||||
*/
|
||||
public function withoutFreshModel(): self;
|
||||
/**
|
||||
* Stop repository update functions from returning a fresh
|
||||
* model when changes are committed.
|
||||
*/
|
||||
public function withoutFreshModel(): self;
|
||||
|
||||
/**
|
||||
* Return a fresh model with a repository updates a model.
|
||||
*/
|
||||
public function withFreshModel(): self;
|
||||
/**
|
||||
* Return a fresh model with a repository updates a model.
|
||||
*/
|
||||
public function withFreshModel(): self;
|
||||
|
||||
/**
|
||||
* Set whether the repository should return a fresh model
|
||||
* when changes are committed.
|
||||
*/
|
||||
public function setFreshModel(bool $fresh = true): self;
|
||||
/**
|
||||
* Set whether the repository should return a fresh model
|
||||
* when changes are committed.
|
||||
*/
|
||||
public function setFreshModel(bool $fresh = true): self;
|
||||
|
||||
/**
|
||||
* Create a new model instance and persist it to the database.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function create(array $fields, bool $validate = true, bool $force = false): mixed;
|
||||
/**
|
||||
* Create a new model instance and persist it to the database.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function create(array $fields, bool $validate = true, bool $force = false): mixed;
|
||||
|
||||
/**
|
||||
* Find a model that has the specific ID passed.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function find(int $id): mixed;
|
||||
/**
|
||||
* Find a model that has the specific ID passed.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function find(int $id): mixed;
|
||||
|
||||
/**
|
||||
* Find a model matching an array of where clauses.
|
||||
*/
|
||||
public function findWhere(array $fields): Collection;
|
||||
/**
|
||||
* Find a model matching an array of where clauses.
|
||||
*/
|
||||
public function findWhere(array $fields): Collection;
|
||||
|
||||
/**
|
||||
* Find and return the first matching instance for the given fields.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function findFirstWhere(array $fields): mixed;
|
||||
/**
|
||||
* Find and return the first matching instance for the given fields.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function findFirstWhere(array $fields): mixed;
|
||||
|
||||
/**
|
||||
* Return a count of records matching the passed arguments.
|
||||
*/
|
||||
public function findCountWhere(array $fields): int;
|
||||
/**
|
||||
* Return a count of records matching the passed arguments.
|
||||
*/
|
||||
public function findCountWhere(array $fields): int;
|
||||
|
||||
/**
|
||||
* Delete a given record from the database.
|
||||
*/
|
||||
public function delete(int $id): int;
|
||||
/**
|
||||
* Delete a given record from the database.
|
||||
*/
|
||||
public function delete(int $id): int;
|
||||
|
||||
/**
|
||||
* Delete records matching the given attributes.
|
||||
*/
|
||||
public function deleteWhere(array $attributes): int;
|
||||
/**
|
||||
* Delete records matching the given attributes.
|
||||
*/
|
||||
public function deleteWhere(array $attributes): int;
|
||||
|
||||
/**
|
||||
* Update a given ID with the passed array of fields.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(int $id, array $fields, bool $validate = true, bool $force = false): mixed;
|
||||
/**
|
||||
* Update a given ID with the passed array of fields.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(int $id, array $fields, bool $validate = true, bool $force = false): mixed;
|
||||
|
||||
/**
|
||||
* Perform a mass update where matching records are updated using whereIn.
|
||||
* This does not perform any model data validation.
|
||||
*/
|
||||
public function updateWhereIn(string $column, array $values, array $fields): int;
|
||||
/**
|
||||
* Perform a mass update where matching records are updated using whereIn.
|
||||
* This does not perform any model data validation.
|
||||
*/
|
||||
public function updateWhereIn(string $column, array $values, array $fields): int;
|
||||
|
||||
/**
|
||||
* Update a record if it exists in the database, otherwise create it.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function updateOrCreate(array $where, array $fields, bool $validate = true, bool $force = false): mixed;
|
||||
/**
|
||||
* Update a record if it exists in the database, otherwise create it.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function updateOrCreate(array $where, array $fields, bool $validate = true, bool $force = false): mixed;
|
||||
|
||||
/**
|
||||
* Return all records associated with the given model.
|
||||
*/
|
||||
public function all(): Collection;
|
||||
/**
|
||||
* Return all records associated with the given model.
|
||||
*/
|
||||
public function all(): Collection;
|
||||
|
||||
/**
|
||||
* Return a paginated result set using a search term if set on the repository.
|
||||
*/
|
||||
public function paginated(int $perPage): LengthAwarePaginator;
|
||||
/**
|
||||
* Return a paginated result set using a search term if set on the repository.
|
||||
*/
|
||||
public function paginated(int $perPage): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Insert a single or multiple records into the database at once skipping
|
||||
* validation and mass assignment checking.
|
||||
*/
|
||||
public function insert(array $data): bool;
|
||||
/**
|
||||
* Insert a single or multiple records into the database at once skipping
|
||||
* validation and mass assignment checking.
|
||||
*/
|
||||
public function insert(array $data): bool;
|
||||
|
||||
/**
|
||||
* Insert multiple records into the database and ignore duplicates.
|
||||
*/
|
||||
public function insertIgnore(array $values): bool;
|
||||
/**
|
||||
* Insert multiple records into the database and ignore duplicates.
|
||||
*/
|
||||
public function insertIgnore(array $values): bool;
|
||||
|
||||
/**
|
||||
* Get the amount of entries in the database.
|
||||
*/
|
||||
public function count(): int;
|
||||
/**
|
||||
* Get the amount of entries in the database.
|
||||
*/
|
||||
public function count(): int;
|
||||
}
|
||||
|
||||
23
app/Contracts/Subdomain/SubdomainFeatureInterface.php
Normal file
23
app/Contracts/Subdomain/SubdomainFeatureInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Contracts\Subdomain;
|
||||
|
||||
use Pterodactyl\Models\Server;
|
||||
|
||||
interface SubdomainFeatureInterface
|
||||
{
|
||||
/**
|
||||
* Get the feature name (e.g., 'subdomain_minecraft', 'subdomain_rust').
|
||||
*/
|
||||
public function getFeatureName(): string;
|
||||
|
||||
/**
|
||||
* Get the DNS records that need to be created for this feature.
|
||||
*
|
||||
* @param Server $server The server instance
|
||||
* @param string $subdomain The subdomain name
|
||||
* @param string $domain The domain name
|
||||
* @return array Array of DNS record configurations
|
||||
*/
|
||||
public function getDnsRecords(Server $server, string $subdomain, string $domain): array;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Events\Auth;
|
||||
|
||||
use Pterodactyl\Events\Event;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class FailedCaptcha extends Event
|
||||
{
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public string $ip, public string $domain)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -14,68 +14,68 @@ use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
class DisplayException extends PterodactylException implements HttpExceptionInterface
|
||||
{
|
||||
public const LEVEL_DEBUG = 'debug';
|
||||
public const LEVEL_INFO = 'info';
|
||||
public const LEVEL_WARNING = 'warning';
|
||||
public const LEVEL_ERROR = 'error';
|
||||
public const LEVEL_DEBUG = 'debug';
|
||||
public const LEVEL_INFO = 'info';
|
||||
public const LEVEL_WARNING = 'warning';
|
||||
public const LEVEL_ERROR = 'error';
|
||||
|
||||
/**
|
||||
* DisplayException constructor.
|
||||
*/
|
||||
public function __construct(string $message, ?\Throwable $previous = null, protected string $level = self::LEVEL_ERROR, int $code = 0)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
/**
|
||||
* DisplayException constructor.
|
||||
*/
|
||||
public function __construct(string $message, ?\Throwable $previous = null, protected string $level = self::LEVEL_ERROR, int $code = 0)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function getErrorLevel(): string
|
||||
{
|
||||
return $this->level;
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return Response::HTTP_BAD_REQUEST;
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the exception to the user by adding a flashed message to the session
|
||||
* and then redirecting them back to the page that they came from. If the
|
||||
* request originated from an API hit, return the error in JSONAPI spec format.
|
||||
*/
|
||||
public function render(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(Handler::toArray($this), $this->getStatusCode(), $this->getHeaders());
|
||||
}
|
||||
|
||||
public function getErrorLevel(): string
|
||||
{
|
||||
return $this->level;
|
||||
app(AlertsMessageBag::class)->danger($this->getMessage())->flash();
|
||||
|
||||
return redirect()->back()->withInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the exception to the logs using the defined error level only if the previous
|
||||
* exception is set.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
if (!$this->getPrevious() instanceof \Exception || !Handler::isReportable($this->getPrevious())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return Response::HTTP_BAD_REQUEST;
|
||||
try {
|
||||
$logger = Container::getInstance()->make(LoggerInterface::class);
|
||||
} catch (\Exception) {
|
||||
throw $this->getPrevious();
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the exception to the user by adding a flashed message to the session
|
||||
* and then redirecting them back to the page that they came from. If the
|
||||
* request originated from an API hit, return the error in JSONAPI spec format.
|
||||
*/
|
||||
public function render(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(Handler::toArray($this), $this->getStatusCode(), $this->getHeaders());
|
||||
}
|
||||
|
||||
app(AlertsMessageBag::class)->danger($this->getMessage())->flash();
|
||||
|
||||
return redirect()->back()->withInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the exception to the logs using the defined error level only if the previous
|
||||
* exception is set.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
if (!$this->getPrevious() instanceof \Exception || !Handler::isReportable($this->getPrevious())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$logger = Container::getInstance()->make(LoggerInterface::class);
|
||||
} catch (\Exception) {
|
||||
throw $this->getPrevious();
|
||||
}
|
||||
|
||||
return $logger->{$this->getErrorLevel()}($this->getPrevious());
|
||||
}
|
||||
return $logger->{$this->getErrorLevel()}($this->getPrevious());
|
||||
}
|
||||
}
|
||||
|
||||
94
app/Exceptions/Dns/DnsProviderException.php
Normal file
94
app/Exceptions/Dns/DnsProviderException.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Exceptions\Dns;
|
||||
|
||||
use Exception;
|
||||
|
||||
class DnsProviderException extends Exception
|
||||
{
|
||||
/**
|
||||
* Create a new DNS provider exception.
|
||||
*/
|
||||
public function __construct(string $message = '', int $code = 0, ?Exception $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for connection failures.
|
||||
*/
|
||||
public static function connectionFailed(string $provider, string $reason = ''): self
|
||||
{
|
||||
$message = "Failed to connect to DNS provider '{$provider}'";
|
||||
if ($reason) {
|
||||
$message .= ": {$reason}";
|
||||
}
|
||||
|
||||
return new self($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for authentication failures.
|
||||
*/
|
||||
public static function authenticationFailed(string $provider): self
|
||||
{
|
||||
return new self("Authentication failed for DNS provider '{$provider}'. Please check your credentials.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for invalid configuration.
|
||||
*/
|
||||
public static function invalidConfiguration(string $provider, string $field): self
|
||||
{
|
||||
return new self("Invalid configuration for DNS provider '{$provider}': missing or invalid field '{$field}'.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for record creation failures.
|
||||
*/
|
||||
public static function recordCreationFailed(string $domain, string $subdomain, string $reason = ''): self
|
||||
{
|
||||
$message = "Failed to create DNS record for '{$subdomain}.{$domain}'";
|
||||
if ($reason) {
|
||||
$message .= ": {$reason}";
|
||||
}
|
||||
|
||||
return new self($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for record update failures.
|
||||
*/
|
||||
public static function recordUpdateFailed(string $domain, array $recordIds, string $reason = ''): self
|
||||
{
|
||||
$recordList = implode(', ', $recordIds);
|
||||
$message = "Failed to update DNS records [{$recordList}] for domain '{$domain}'";
|
||||
if ($reason) {
|
||||
$message .= ": {$reason}";
|
||||
}
|
||||
|
||||
return new self($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for record deletion failures.
|
||||
*/
|
||||
public static function recordDeletionFailed(string $domain, array $recordIds, string $reason = ''): self
|
||||
{
|
||||
$recordList = implode(', ', $recordIds);
|
||||
$message = "Failed to delete DNS records [{$recordList}] for domain '{$domain}'";
|
||||
if ($reason) {
|
||||
$message .= ": {$reason}";
|
||||
}
|
||||
|
||||
return new self($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exception for unsupported record types.
|
||||
*/
|
||||
public static function unsupportedRecordType(string $provider, string $recordType): self
|
||||
{
|
||||
return new self("DNS provider '{$provider}' does not support record type '{$recordType}'.");
|
||||
}
|
||||
}
|
||||
57
app/Exceptions/ServerOperations/ServerOperationException.php
Normal file
57
app/Exceptions/ServerOperations/ServerOperationException.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Exceptions\ServerOperations;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ServerOperationException extends Exception
|
||||
{
|
||||
/**
|
||||
* Create a new server operation exception.
|
||||
*/
|
||||
public function __construct(string $message = '', int $code = 0, ?Exception $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for when server cannot accept operations
|
||||
*/
|
||||
public static function serverBusy(string $serverUuid): self
|
||||
{
|
||||
return new self("Server {$serverUuid} is currently busy and cannot accept new operations.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for operation timeout
|
||||
*/
|
||||
public static function operationTimedOut(string $operationId): self
|
||||
{
|
||||
return new self("Operation {$operationId} has timed out.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for invalid operation state
|
||||
*/
|
||||
public static function invalidOperationState(string $operationId, string $currentState): self
|
||||
{
|
||||
return new self("Operation {$operationId} is in an invalid state: {$currentState}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for operation not found
|
||||
*/
|
||||
public static function operationNotFound(string $operationId): self
|
||||
{
|
||||
return new self("Operation {$operationId} was not found.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for rate limit exceeded
|
||||
*/
|
||||
public static function rateLimitExceeded(string $operationType, int $windowSeconds): self
|
||||
{
|
||||
$minutes = ceil($windowSeconds / 60);
|
||||
return new self("Rate limit exceeded for {$operationType} operations. Please wait {$minutes} minutes before trying again.");
|
||||
}
|
||||
}
|
||||
12
app/Exceptions/Service/Backup/BackupFailedException.php
Normal file
12
app/Exceptions/Service/Backup/BackupFailedException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Exceptions\Service\Backup;
|
||||
|
||||
use Pterodactyl\Exceptions\DisplayException;
|
||||
|
||||
class BackupFailedException extends DisplayException
|
||||
{
|
||||
/**
|
||||
* Exception thrown when a backup fails to complete successfully.
|
||||
*/
|
||||
}
|
||||
@@ -11,25 +11,25 @@ use Illuminate\Contracts\View\Factory as ViewFactory;
|
||||
|
||||
class NodeController extends Controller
|
||||
{
|
||||
/**
|
||||
* NodeController constructor.
|
||||
*/
|
||||
public function __construct(private ViewFactory $view)
|
||||
{
|
||||
}
|
||||
/**
|
||||
* NodeController constructor.
|
||||
*/
|
||||
public function __construct(private ViewFactory $view)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a listing of nodes on the system.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$nodes = QueryBuilder::for(
|
||||
Node::query()->with('location')->withCount('servers')
|
||||
)
|
||||
->allowedFilters(['uuid', 'name'])
|
||||
->allowedSorts(['id'])
|
||||
->paginate(25);
|
||||
/**
|
||||
* Returns a listing of nodes on the system.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$nodes = QueryBuilder::for(
|
||||
Node::query()->with('location')->withCount('servers')
|
||||
)
|
||||
->allowedFilters(['uuid', 'name'])
|
||||
->allowedSorts(['id'])
|
||||
->paginate(25);
|
||||
|
||||
return $this->view->make('admin.nodes.index', ['nodes' => $nodes]);
|
||||
}
|
||||
return $this->view->make('admin.nodes.index', ['nodes' => $nodes]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,30 +11,30 @@ use Pterodactyl\Repositories\Wings\DaemonConfigurationRepository;
|
||||
|
||||
class SystemInformationController extends Controller
|
||||
{
|
||||
/**
|
||||
* SystemInformationController constructor.
|
||||
*/
|
||||
public function __construct(private DaemonConfigurationRepository $repository)
|
||||
{
|
||||
}
|
||||
/**
|
||||
* SystemInformationController constructor.
|
||||
*/
|
||||
public function __construct(private DaemonConfigurationRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns system information from the Daemon.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
|
||||
*/
|
||||
public function __invoke(Request $request, Node $node): JsonResponse
|
||||
{
|
||||
$data = $this->repository->setNode($node)->getSystemInformation();
|
||||
/**
|
||||
* Returns system information from the Daemon.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
|
||||
*/
|
||||
public function __invoke(Request $request, Node $node): JsonResponse
|
||||
{
|
||||
$data = $this->repository->setNode($node)->getSystemInformation();
|
||||
|
||||
return new JsonResponse([
|
||||
'version' => $data['version'] ?? '',
|
||||
'system' => [
|
||||
'type' => Str::title($data['os'] ?? 'Unknown'),
|
||||
'arch' => $data['architecture'] ?? '--',
|
||||
'release' => $data['kernel_version'] ?? '--',
|
||||
'cpus' => $data['cpu_count'] ?? 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
return new JsonResponse([
|
||||
'version' => $data['version'] ?? '',
|
||||
'system' => [
|
||||
'type' => Str::title($data['os'] ?? 'Unknown'),
|
||||
'arch' => $data['architecture'] ?? '--',
|
||||
'release' => $data['kernel_version'] ?? '--',
|
||||
'cpus' => $data['cpu_count'] ?? 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ class NodesController extends Controller
|
||||
{
|
||||
$this->allocationRepository->update($request->input('allocation_id'), [
|
||||
'ip_alias' => (empty($request->input('alias'))) ? null : $request->input('alias'),
|
||||
]);
|
||||
], false); // Skip validation
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
@@ -144,8 +144,9 @@ class ServersController extends Controller
|
||||
try {
|
||||
$this->buildModificationService->handle($server, $request->only([
|
||||
'allocation_id', 'add_allocations', 'remove_allocations',
|
||||
'memory', 'swap', 'io', 'cpu', 'threads', 'disk',
|
||||
'memory', 'overhead_memory', 'swap', 'io', 'cpu', 'threads', 'disk',
|
||||
'database_limit', 'allocation_limit', 'backup_limit', 'oom_disabled',
|
||||
'exclude_from_resource_calculation',
|
||||
]));
|
||||
} catch (DataValidationException $exception) {
|
||||
throw new ValidationException($exception->getValidator());
|
||||
|
||||
@@ -14,49 +14,41 @@ use Pterodactyl\Http\Requests\Admin\Settings\AdvancedSettingsFormRequest;
|
||||
|
||||
class AdvancedController extends Controller
|
||||
{
|
||||
/**
|
||||
* AdvancedController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private AlertsMessageBag $alert,
|
||||
private ConfigRepository $config,
|
||||
private Kernel $kernel,
|
||||
private SettingsRepositoryInterface $settings,
|
||||
private ViewFactory $view,
|
||||
) {
|
||||
/**
|
||||
* AdvancedController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private AlertsMessageBag $alert,
|
||||
private ConfigRepository $config,
|
||||
private Kernel $kernel,
|
||||
private SettingsRepositoryInterface $settings,
|
||||
private ViewFactory $view,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render advanced Panel settings UI.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return $this->view->make('admin.settings.advanced');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update advanced settings.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(AdvancedSettingsFormRequest $request): RedirectResponse
|
||||
{
|
||||
foreach ($request->normalize() as $key => $value) {
|
||||
$this->settings->set('settings::' . $key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render advanced Panel settings UI.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$showRecaptchaWarning = false;
|
||||
if (
|
||||
$this->config->get('recaptcha._shipped_secret_key') === $this->config->get('recaptcha.secret_key')
|
||||
|| $this->config->get('recaptcha._shipped_website_key') === $this->config->get('recaptcha.website_key')
|
||||
) {
|
||||
$showRecaptchaWarning = true;
|
||||
}
|
||||
$this->kernel->call('queue:restart');
|
||||
$this->alert->success('Advanced settings have been updated successfully and the queue worker was restarted to apply these changes.')->flash();
|
||||
|
||||
return $this->view->make('admin.settings.advanced', [
|
||||
'showRecaptchaWarning' => $showRecaptchaWarning,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(AdvancedSettingsFormRequest $request): RedirectResponse
|
||||
{
|
||||
foreach ($request->normalize() as $key => $value) {
|
||||
$this->settings->set('settings::' . $key, $value);
|
||||
}
|
||||
|
||||
$this->kernel->call('queue:restart');
|
||||
$this->alert->success('Advanced settings have been updated successfully and the queue worker was restarted to apply these changes.')->flash();
|
||||
|
||||
return redirect()->route('admin.settings.advanced');
|
||||
}
|
||||
}
|
||||
return redirect()->route('admin.settings.advanced');
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Admin/Settings/CaptchaController.php
Normal file
69
app/Http/Controllers/Admin/Settings/CaptchaController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Admin\Settings;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Prologue\Alerts\AlertsMessageBag;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\View\Factory as ViewFactory;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Pterodactyl\Services\Captcha\CaptchaManager;
|
||||
use Illuminate\Contracts\Encryption\Encrypter;
|
||||
use Pterodactyl\Contracts\Repository\SettingsRepositoryInterface;
|
||||
use Pterodactyl\Http\Requests\Admin\Settings\CaptchaSettingsFormRequest;
|
||||
|
||||
class CaptchaController extends Controller
|
||||
{
|
||||
/**
|
||||
* CaptchaController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private AlertsMessageBag $alert,
|
||||
private CaptchaManager $captcha,
|
||||
private Encrypter $encrypter,
|
||||
private Kernel $kernel,
|
||||
private SettingsRepositoryInterface $settings,
|
||||
private ViewFactory $view,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Render captcha settings UI.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return $this->view->make('admin.settings.captcha', [
|
||||
'providers' => [
|
||||
'none' => 'Disabled',
|
||||
'turnstile' => 'Cloudflare Turnstile',
|
||||
'hcaptcha' => 'hCaptcha',
|
||||
'recaptcha' => 'Google reCAPTCHA',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update captcha settings.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(CaptchaSettingsFormRequest $request): RedirectResponse
|
||||
{
|
||||
$values = $request->normalize();
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
// Encrypt secret keys before storing
|
||||
if (in_array($key, \Pterodactyl\Providers\SettingsServiceProvider::getEncryptedKeys()) && !empty($value)) {
|
||||
$value = $this->encrypter->encrypt($value);
|
||||
}
|
||||
|
||||
$this->settings->set('settings::' . $key, $value);
|
||||
}
|
||||
|
||||
$this->kernel->call('queue:restart');
|
||||
$this->alert->success('Captcha settings have been updated successfully and the queue worker was restarted to apply these changes.')->flash();
|
||||
|
||||
return redirect()->route('admin.settings.captcha');
|
||||
}
|
||||
}
|
||||
247
app/Http/Controllers/Admin/Settings/DomainsController.php
Normal file
247
app/Http/Controllers/Admin/Settings/DomainsController.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Admin\Settings;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Pterodactyl\Models\Domain;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Pterodactyl\Services\Subdomain\SubdomainManagementService;
|
||||
use Pterodactyl\Exceptions\Dns\DnsProviderException;
|
||||
use Illuminate\Contracts\View\Factory as ViewFactory;
|
||||
use Pterodactyl\Http\Requests\Admin\Settings\DomainFormRequest;
|
||||
|
||||
class DomainsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ViewFactory $view,
|
||||
private SubdomainManagementService $subdomainService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the domains management page.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$domains = Domain::withCount('serverSubdomains')->orderBy('created_at', 'desc')->get();
|
||||
$availableProviders = $this->getAvailableProviders();
|
||||
|
||||
return $this->view->make('admin.settings.domains.index', [
|
||||
'domains' => $domains,
|
||||
'providers' => $availableProviders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new domain.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$availableProviders = $this->getAvailableProviders();
|
||||
|
||||
return $this->view->make('admin.settings.domains.create', [
|
||||
'providers' => $availableProviders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created domain.
|
||||
*/
|
||||
public function store(DomainFormRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
try {
|
||||
// Test the DNS provider connection
|
||||
$providerClass = $this->getProviderClass($data['dns_provider']);
|
||||
$provider = new $providerClass($data['dns_config']);
|
||||
$provider->testConnection();
|
||||
|
||||
// Handle domain creation in a transaction
|
||||
\DB::transaction(function () use ($data) {
|
||||
// If this domain is being set as default, remove default from other domains
|
||||
if (!empty($data['is_default'])) {
|
||||
Domain::where('is_default', true)->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
// Create the domain
|
||||
Domain::create([
|
||||
'name' => $data['name'],
|
||||
'dns_provider' => $data['dns_provider'],
|
||||
'dns_config' => $data['dns_config'],
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'is_default' => $data['is_default'] ?? false,
|
||||
]);
|
||||
});
|
||||
|
||||
return redirect()->route('admin.settings.domains.index')
|
||||
->with('success', 'Domain created successfully.');
|
||||
} catch (DnsProviderException $e) {
|
||||
return back()->withInput()->withErrors(['dns_config' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return back()->withInput()->withErrors(['general' => 'Failed to create domain: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing a domain.
|
||||
*/
|
||||
public function edit(Domain $domain): View
|
||||
{
|
||||
$domain->load('serverSubdomains');
|
||||
$availableProviders = $this->getAvailableProviders();
|
||||
|
||||
return $this->view->make('admin.settings.domains.edit', [
|
||||
'domain' => $domain,
|
||||
'providers' => $availableProviders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified domain.
|
||||
*/
|
||||
public function update(DomainFormRequest $request, Domain $domain): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
try {
|
||||
// Test the DNS provider connection if config changed
|
||||
if ($data['dns_config'] !== $domain->dns_config || $data['dns_provider'] !== $domain->dns_provider) {
|
||||
$providerClass = $this->getProviderClass($data['dns_provider']);
|
||||
$provider = new $providerClass($data['dns_config']);
|
||||
$provider->testConnection();
|
||||
}
|
||||
|
||||
// Handle domain update in a transaction
|
||||
\DB::transaction(function () use ($data, $domain) {
|
||||
// Handle default domain changes
|
||||
$newIsDefault = $data['is_default'] ?? false;
|
||||
if ($newIsDefault && !$domain->is_default) {
|
||||
// If this domain is being set as default, remove default from other domains
|
||||
Domain::where('is_default', true)->update(['is_default' => false]);
|
||||
} elseif (!$newIsDefault && $domain->is_default) {
|
||||
// Don't allow removing default status if this is the only default domain
|
||||
$defaultCount = Domain::where('is_default', true)->count();
|
||||
if ($defaultCount <= 1) {
|
||||
throw new \Exception('Cannot remove default status: At least one domain must be set as default.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update the domain
|
||||
$domain->update([
|
||||
'name' => $data['name'],
|
||||
'dns_provider' => $data['dns_provider'],
|
||||
'dns_config' => $data['dns_config'],
|
||||
'is_active' => $data['is_active'] ?? $domain->is_active,
|
||||
'is_default' => $newIsDefault,
|
||||
]);
|
||||
});
|
||||
|
||||
return redirect()->route('admin.settings.domains.index')
|
||||
->with('success', 'Domain updated successfully.');
|
||||
} catch (DnsProviderException $e) {
|
||||
return back()->withInput()->withErrors(['dns_config' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return back()->withInput()->withErrors(['general' => 'Failed to update domain: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified domain.
|
||||
*/
|
||||
public function destroy(Domain $domain): RedirectResponse
|
||||
{
|
||||
try {
|
||||
// Check if domain has active subdomains
|
||||
$activeSubdomains = $domain->activeSubdomains()->count();
|
||||
if ($activeSubdomains > 0) {
|
||||
return back()->withErrors(['general' => "Cannot delete domain with {$activeSubdomains} active subdomains."]);
|
||||
}
|
||||
|
||||
// Don't allow deleting the only default domain
|
||||
if ($domain->is_default) {
|
||||
$defaultCount = Domain::where('is_default', true)->count();
|
||||
if ($defaultCount <= 1) {
|
||||
return back()->withErrors(['general' => 'Cannot delete the only default domain. Please set another domain as default first.']);
|
||||
}
|
||||
}
|
||||
|
||||
$domain->delete();
|
||||
|
||||
return redirect()->route('admin.settings.domains.index')
|
||||
->with('success', 'Domain deleted successfully.');
|
||||
} catch (\Exception $e) {
|
||||
return back()->withErrors(['general' => 'Failed to delete domain: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to a DNS provider.
|
||||
*/
|
||||
public function testConnection(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'dns_provider' => 'required|string',
|
||||
'dns_config' => 'required|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$providerClass = $this->getProviderClass($request->input('dns_provider'));
|
||||
$provider = new $providerClass($request->input('dns_config'));
|
||||
$provider->testConnection();
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Connection successful.']);
|
||||
} catch (DnsProviderException $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 400);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => 'Connection failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration schema for a DNS provider.
|
||||
*/
|
||||
public function getProviderSchema(string $provider): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
try {
|
||||
$providerClass = $this->getProviderClass($provider);
|
||||
$providerInstance = new $providerClass([]);
|
||||
$schema = $providerInstance->getConfigurationSchema();
|
||||
|
||||
return response()->json(['success' => true, 'schema' => $schema]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available DNS providers.
|
||||
*/
|
||||
private function getAvailableProviders(): array
|
||||
{
|
||||
return [
|
||||
'cloudflare' => [
|
||||
'name' => 'Cloudflare',
|
||||
'description' => 'Cloudflare DNS service',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider class for a given provider name.
|
||||
*/
|
||||
private function getProviderClass(string $provider): string
|
||||
{
|
||||
$providers = [
|
||||
'cloudflare' => \Pterodactyl\Services\Dns\Providers\CloudflareProvider::class,
|
||||
];
|
||||
|
||||
if (!isset($providers[$provider])) {
|
||||
throw new \Exception("Unsupported DNS provider: {$provider}");
|
||||
}
|
||||
|
||||
return $providers[$provider];
|
||||
}
|
||||
}
|
||||
@@ -15,46 +15,46 @@ use Pterodactyl\Http\Requests\Admin\Settings\BaseSettingsFormRequest;
|
||||
|
||||
class IndexController extends Controller
|
||||
{
|
||||
use AvailableLanguages;
|
||||
use AvailableLanguages;
|
||||
|
||||
/**
|
||||
* IndexController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private AlertsMessageBag $alert,
|
||||
private Kernel $kernel,
|
||||
private SettingsRepositoryInterface $settings,
|
||||
private SoftwareVersionService $versionService,
|
||||
private ViewFactory $view,
|
||||
) {
|
||||
/**
|
||||
* IndexController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private AlertsMessageBag $alert,
|
||||
private Kernel $kernel,
|
||||
private SettingsRepositoryInterface $settings,
|
||||
private SoftwareVersionService $versionService,
|
||||
private ViewFactory $view,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the UI for basic Panel settings.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return $this->view->make('admin.settings.index', [
|
||||
'version' => $this->versionService,
|
||||
'languages' => $this->getAvailableLanguages(true),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle settings update.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(BaseSettingsFormRequest $request): RedirectResponse
|
||||
{
|
||||
foreach ($request->normalize() as $key => $value) {
|
||||
$this->settings->set('settings::' . $key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the UI for basic Panel settings.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return $this->view->make('admin.settings.index', [
|
||||
'version' => $this->versionService,
|
||||
'languages' => $this->getAvailableLanguages(true),
|
||||
]);
|
||||
}
|
||||
$this->kernel->call('queue:restart');
|
||||
$this->alert->success('Panel settings have been updated successfully and the queue worker was restarted to apply these changes.')->flash();
|
||||
|
||||
/**
|
||||
* Handle settings update.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(BaseSettingsFormRequest $request): RedirectResponse
|
||||
{
|
||||
foreach ($request->normalize() as $key => $value) {
|
||||
$this->settings->set('settings::' . $key, $value);
|
||||
}
|
||||
|
||||
$this->kernel->call('queue:restart');
|
||||
$this->alert->success('Panel settings have been updated successfully and the queue worker was restarted to apply these changes.')->flash();
|
||||
|
||||
return redirect()->route('admin.settings');
|
||||
}
|
||||
return redirect()->route('admin.settings');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,90 +18,90 @@ use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController;
|
||||
|
||||
class NodeController extends ApplicationApiController
|
||||
{
|
||||
/**
|
||||
* NodeController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private NodeCreationService $creationService,
|
||||
private NodeDeletionService $deletionService,
|
||||
private NodeUpdateService $updateService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
/**
|
||||
* NodeController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private NodeCreationService $creationService,
|
||||
private NodeDeletionService $deletionService,
|
||||
private NodeUpdateService $updateService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all the nodes currently available on the Panel.
|
||||
*/
|
||||
public function index(GetNodesRequest $request): array
|
||||
{
|
||||
$nodes = QueryBuilder::for(Node::query())
|
||||
->allowedFilters(['uuid', 'name', 'fqdn', 'daemon_token_id'])
|
||||
->allowedSorts(['id', 'uuid', 'memory', 'disk'])
|
||||
->paginate($request->query('per_page') ?? 50);
|
||||
/**
|
||||
* Return all the nodes currently available on the Panel.
|
||||
*/
|
||||
public function index(GetNodesRequest $request): array
|
||||
{
|
||||
$nodes = QueryBuilder::for(Node::query())
|
||||
->allowedFilters(['uuid', 'name', 'fqdn', 'daemon_token_id'])
|
||||
->allowedSorts(['id', 'uuid', 'memory', 'disk'])
|
||||
->paginate($request->query('per_page') ?? 50);
|
||||
|
||||
return $this->fractal->collection($nodes)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
return $this->fractal->collection($nodes)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return data for a single instance of a node.
|
||||
*/
|
||||
public function view(GetNodeRequest $request, Node $node): array
|
||||
{
|
||||
return $this->fractal->item($node)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
/**
|
||||
* Return data for a single instance of a node.
|
||||
*/
|
||||
public function view(GetNodeRequest $request, Node $node): array
|
||||
{
|
||||
return $this->fractal->item($node)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node on the Panel. Returns the created node and an HTTP/201
|
||||
* status response on success.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function store(StoreNodeRequest $request): JsonResponse
|
||||
{
|
||||
$node = $this->creationService->handle($request->validated());
|
||||
/**
|
||||
* Create a new node on the Panel. Returns the created node and an HTTP/201
|
||||
* status response on success.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function store(StoreNodeRequest $request): JsonResponse
|
||||
{
|
||||
$node = $this->creationService->handle($request->validated());
|
||||
|
||||
return $this->fractal->item($node)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
->addMeta([
|
||||
'resource' => route('api.application.nodes.view', [
|
||||
'node' => $node->id,
|
||||
]),
|
||||
])
|
||||
->respond(201);
|
||||
}
|
||||
return $this->fractal->item($node)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
->addMeta([
|
||||
'resource' => route('api.application.nodes.view', [
|
||||
'node' => $node->id,
|
||||
]),
|
||||
])
|
||||
->respond(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing node on the Panel.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function update(UpdateNodeRequest $request, Node $node): array
|
||||
{
|
||||
$node = $this->updateService->handle(
|
||||
$node,
|
||||
$request->validated(),
|
||||
$request->input('reset_secret') === true
|
||||
);
|
||||
/**
|
||||
* Update an existing node on the Panel.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function update(UpdateNodeRequest $request, Node $node): array
|
||||
{
|
||||
$node = $this->updateService->handle(
|
||||
$node,
|
||||
$request->validated(),
|
||||
$request->input('reset_secret') === true
|
||||
);
|
||||
|
||||
return $this->fractal->item($node)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
return $this->fractal->item($node)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a given node from the Panel as long as there are no servers
|
||||
* currently attached to it.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Service\HasActiveServersException
|
||||
*/
|
||||
public function delete(DeleteNodeRequest $request, Node $node): JsonResponse
|
||||
{
|
||||
$this->deletionService->handle($node);
|
||||
/**
|
||||
* Deletes a given node from the Panel as long as there are no servers
|
||||
* currently attached to it.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Service\HasActiveServersException
|
||||
*/
|
||||
public function delete(DeleteNodeRequest $request, Node $node): JsonResponse
|
||||
{
|
||||
$this->deletionService->handle($node);
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Nests;
|
||||
|
||||
use Pterodactyl\Models\Egg;
|
||||
use Pterodactyl\Models\Nest;
|
||||
use Pterodactyl\Transformers\Api\Client\EggTransformer;
|
||||
use Pterodactyl\Transformers\Api\Application\EggTransformer;
|
||||
use Pterodactyl\Http\Requests\Api\Application\Nests\Eggs\GetEggRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Application\Nests\Eggs\GetEggsRequest;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController;
|
||||
|
||||
class EggController extends ClientApiController
|
||||
class EggController extends ApplicationApiController
|
||||
{
|
||||
/**
|
||||
* Return all eggs that exist for a given nest.
|
||||
|
||||
@@ -13,6 +13,7 @@ use Pterodactyl\Services\Backups\DeleteBackupService;
|
||||
use Pterodactyl\Services\Backups\DownloadLinkService;
|
||||
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||
use Pterodactyl\Services\Backups\InitiateBackupService;
|
||||
use Pterodactyl\Services\Backups\ServerStateService;
|
||||
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
|
||||
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
@@ -22,203 +23,334 @@ use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
|
||||
|
||||
class BackupController extends ClientApiController
|
||||
{
|
||||
/**
|
||||
* BackupController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private DaemonBackupRepository $daemonRepository,
|
||||
private DeleteBackupService $deleteBackupService,
|
||||
private InitiateBackupService $initiateBackupService,
|
||||
private DownloadLinkService $downloadLinkService,
|
||||
private BackupRepository $repository,
|
||||
) {
|
||||
parent::__construct();
|
||||
/**
|
||||
* BackupController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private DaemonBackupRepository $daemonRepository,
|
||||
private DeleteBackupService $deleteBackupService,
|
||||
private InitiateBackupService $initiateBackupService,
|
||||
private DownloadLinkService $downloadLinkService,
|
||||
private BackupRepository $repository,
|
||||
private ServerStateService $serverStateService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the backups for a given server instance in a paginated
|
||||
* result set.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(Request $request, Server $server): array
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the backups for a given server instance in a paginated
|
||||
* result set.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(Request $request, Server $server): array
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
$limit = min($request->query('per_page') ?? 20, 50);
|
||||
|
||||
$limit = min($request->query('per_page') ?? 20, 50);
|
||||
// Sort backups: locked ones first, then by created_at descending (latest first)
|
||||
$backups = $server->backups()
|
||||
->orderByRaw('is_locked DESC, created_at DESC')
|
||||
->paginate($limit);
|
||||
|
||||
return $this->fractal->collection($server->backups()->paginate($limit))
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->addMeta([
|
||||
'backup_count' => $this->repository->getNonFailedBackups($server)->count(),
|
||||
])
|
||||
->toArray();
|
||||
return $this->fractal->collection($backups)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->addMeta([
|
||||
'backup_count' => $this->repository->getNonFailedBackups($server)->count(),
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the backup process for a server.
|
||||
*
|
||||
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
|
||||
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function store(StoreBackupRequest $request, Server $server): array
|
||||
{
|
||||
$action = $this->initiateBackupService
|
||||
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
|
||||
|
||||
// Only set the lock status if the user even has permission to delete backups,
|
||||
// otherwise ignore this status. This gets a little funky since it isn't clear
|
||||
// how best to allow a user to create a backup that is locked without also preventing
|
||||
// them from just filling up a server with backups that can never be deleted?
|
||||
if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
$action->setIsLocked((bool) $request->input('is_locked'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the backup process for a server.
|
||||
*
|
||||
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
|
||||
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function store(StoreBackupRequest $request, Server $server): array
|
||||
{
|
||||
$action = $this->initiateBackupService
|
||||
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
|
||||
$backup = $action->handle($server, $request->input('name'));
|
||||
|
||||
// Only set the lock status if the user even has permission to delete backups,
|
||||
// otherwise ignore this status. This gets a little funky since it isn't clear
|
||||
// how best to allow a user to create a backup that is locked without also preventing
|
||||
// them from just filling up a server with backups that can never be deleted?
|
||||
if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
$action->setIsLocked((bool) $request->input('is_locked'));
|
||||
}
|
||||
Activity::event('server:backup.start')
|
||||
->subject($backup)
|
||||
->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')])
|
||||
->log();
|
||||
|
||||
$backup = $action->handle($server, $request->input('name'));
|
||||
return $this->fractal->item($backup)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
Activity::event('server:backup.start')
|
||||
->subject($backup)
|
||||
->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')])
|
||||
->log();
|
||||
|
||||
return $this->fractal->item($backup)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->toArray();
|
||||
/**
|
||||
* Toggles the lock status of a given backup for a server.
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function toggleLock(Request $request, Server $server, Backup $backup): array
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the lock status of a given backup for a server.
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function toggleLock(Request $request, Server $server, Backup $backup): array
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
$action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock';
|
||||
|
||||
$action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock';
|
||||
$backup->update(['is_locked' => !$backup->is_locked]);
|
||||
|
||||
$backup->update(['is_locked' => !$backup->is_locked]);
|
||||
Activity::event($action)->subject($backup)->property('name', $backup->name)->log();
|
||||
|
||||
Activity::event($action)->subject($backup)->property('name', $backup->name)->log();
|
||||
return $this->fractal->item($backup)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return $this->fractal->item($backup)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->toArray();
|
||||
/**
|
||||
* Rename a backup.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function rename(Request $request, Server $server, Backup $backup): array
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about a single backup.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function view(Request $request, Server $server, Backup $backup): array
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
$request->validate([
|
||||
'name' => 'required|string|min:1|max:191',
|
||||
]);
|
||||
|
||||
return $this->fractal->item($backup)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->toArray();
|
||||
$oldName = $backup->name;
|
||||
$newName = trim($request->input('name'));
|
||||
|
||||
// Sanitize backup name to prevent injection
|
||||
$newName = preg_replace('/[^a-zA-Z0-9\s\-_\.\(\)→:,]/', '', $newName);
|
||||
$newName = substr($newName, 0, 191); // Limit to database field length
|
||||
|
||||
if (empty($newName)) {
|
||||
throw new BadRequestHttpException('Backup name cannot be empty after sanitization.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a backup from the panel as well as the remote source where it is currently
|
||||
* being stored.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
$backup->update(['name' => $newName]);
|
||||
|
||||
$this->deleteBackupService->handle($backup);
|
||||
Activity::event('server:backup.rename')
|
||||
->subject($backup)
|
||||
->property([
|
||||
'old_name' => $oldName,
|
||||
'new_name' => $newName,
|
||||
])
|
||||
->log();
|
||||
|
||||
Activity::event('server:backup.delete')
|
||||
->subject($backup)
|
||||
->property(['name' => $backup->name, 'failed' => !$backup->is_successful])
|
||||
->log();
|
||||
return $this->fractal->item($backup)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
/**
|
||||
* Returns information about a single backup.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function view(Request $request, Server $server, Backup $backup): array
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the backup for a given server instance. For daemon local files, the file
|
||||
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
|
||||
* which the user is redirected to.
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function download(Request $request, Server $server, Backup $backup): JsonResponse
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
return $this->fractal->item($backup)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_WINGS) {
|
||||
throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.');
|
||||
}
|
||||
/**
|
||||
* Deletes a backup from the panel as well as the remote source where it is currently
|
||||
* being stored.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$url = $this->downloadLinkService->handle($backup, $request->user());
|
||||
$this->deleteBackupService->handle($backup);
|
||||
|
||||
Activity::event('server:backup.download')->subject($backup)->property('name', $backup->name)->log();
|
||||
Activity::event('server:backup.delete')
|
||||
->subject($backup)
|
||||
->property(['name' => $backup->name, 'failed' => !$backup->is_successful])
|
||||
->log();
|
||||
|
||||
return new JsonResponse([
|
||||
'object' => 'signed_url',
|
||||
'attributes' => ['url' => $url],
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the backup for a given server instance. For daemon local files, the file
|
||||
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
|
||||
* which the user is redirected to.
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function download(Request $request, Server $server, Backup $backup): JsonResponse
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_WINGS) {
|
||||
throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.');
|
||||
}
|
||||
|
||||
$url = $this->downloadLinkService->handle($backup, $request->user());
|
||||
|
||||
Activity::event('server:backup.download')->subject($backup)->property('name', $backup->name)->log();
|
||||
|
||||
return new JsonResponse([
|
||||
'object' => 'signed_url',
|
||||
'attributes' => ['url' => $url],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles restoring a backup by making a request to the Wings instance telling it
|
||||
* to begin the process of finding (or downloading) the backup and unpacking it
|
||||
* over the server files.
|
||||
*
|
||||
* All files that currently exist on the server will be deleted before restoring
|
||||
* the backup to ensure a clean restoration process.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse
|
||||
{
|
||||
$this->validateServerForRestore($server);
|
||||
|
||||
$this->validateBackupForRestore($backup);
|
||||
|
||||
// Validate server state compatibility if backup has state data
|
||||
if ($this->serverStateService->hasServerState($backup)) {
|
||||
$compatibility = $this->serverStateService->validateRestoreCompatibility($backup);
|
||||
|
||||
if (!empty($compatibility['errors'])) {
|
||||
throw new BadRequestHttpException('Cannot restore backup: ' . implode(' ', $compatibility['errors']));
|
||||
}
|
||||
|
||||
// Log warnings for user awareness
|
||||
if (!empty($compatibility['warnings'])) {
|
||||
\Log::warning('Backup restore compatibility warnings', [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'server_uuid' => $server->uuid,
|
||||
'warnings' => $compatibility['warnings'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles restoring a backup by making a request to the Wings instance telling it
|
||||
* to begin the process of finding (or downloading) the backup and unpacking it
|
||||
* over the server files.
|
||||
*
|
||||
* If the "truncate" flag is passed through in this request then all the
|
||||
* files that currently exist on the server will be deleted before restoring.
|
||||
* Otherwise, the archive will simply be unpacked over the existing files.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse
|
||||
{
|
||||
// Cannot restore a backup unless a server is fully installed and not currently
|
||||
// processing a different backup restoration request.
|
||||
if (!is_null($server->status)) {
|
||||
throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.');
|
||||
$hasServerState = $this->serverStateService->hasServerState($backup);
|
||||
|
||||
$log = Activity::event('server:backup.restore')
|
||||
->subject($backup)
|
||||
->property([
|
||||
'name' => $backup->name,
|
||||
'truncate' => true,
|
||||
'has_server_state' => $hasServerState,
|
||||
]);
|
||||
|
||||
$log->transaction(function () use ($backup, $server, $request, $hasServerState) {
|
||||
// Double-check server state within transaction to prevent race conditions
|
||||
$server->refresh();
|
||||
if (!is_null($server->status)) {
|
||||
throw new BadRequestHttpException('Server state changed during restore initiation. Please try again.');
|
||||
}
|
||||
|
||||
// If the backup is for an S3 file we need to generate a unique Download link for
|
||||
// it that will allow Wings to actually access the file.
|
||||
$url = null;
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
try {
|
||||
$url = $this->downloadLinkService->handle($backup, $request->user());
|
||||
} catch (\Exception $e) {
|
||||
throw new BadRequestHttpException('Failed to generate download link for S3 backup: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!$backup->is_successful && is_null($backup->completed_at)) {
|
||||
throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.');
|
||||
// Update the status right away for the server so that we know not to allow certain
|
||||
// actions against it via the Panel API.
|
||||
$server->update(['status' => Server::STATUS_RESTORING_BACKUP]);
|
||||
|
||||
try {
|
||||
// Start the file restoration process on Wings (always truncate for clean restore)
|
||||
$this->daemonRepository->setServer($server)->restore($backup, $url);
|
||||
|
||||
// If backup has server state, restore it immediately
|
||||
// This is safe to do now since we're in a transaction and the daemon request succeeded
|
||||
if ($hasServerState) {
|
||||
$this->serverStateService->restoreServerState($server, $backup);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If either daemon request or state restoration fails, reset server status
|
||||
$server->update(['status' => null]);
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
|
||||
$log = Activity::event('server:backup.restore')
|
||||
->subject($backup)
|
||||
->property(['name' => $backup->name, 'truncate' => $request->input('truncate')]);
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
$log->transaction(function () use ($backup, $server, $request) {
|
||||
// If the backup is for an S3 file we need to generate a unique Download link for
|
||||
// it that will allow Wings to actually access the file.
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
$url = $this->downloadLinkService->handle($backup, $request->user());
|
||||
}
|
||||
|
||||
// Update the status right away for the server so that we know not to allow certain
|
||||
// actions against it via the Panel API.
|
||||
$server->update(['status' => Server::STATUS_RESTORING_BACKUP]);
|
||||
|
||||
$this->daemonRepository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate'));
|
||||
});
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
/**
|
||||
* Validate server state for backup restoration
|
||||
*/
|
||||
private function validateServerForRestore(Server $server): void
|
||||
{
|
||||
// Cannot restore a backup unless a server is fully installed and not currently
|
||||
// processing a different backup restoration request.
|
||||
if (!is_null($server->status)) {
|
||||
throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.');
|
||||
}
|
||||
|
||||
if ($server->isSuspended()) {
|
||||
throw new BadRequestHttpException('Cannot restore backup for suspended server.');
|
||||
}
|
||||
|
||||
if (!$server->isInstalled()) {
|
||||
throw new BadRequestHttpException('Cannot restore backup for server that is not fully installed.');
|
||||
}
|
||||
|
||||
if ($server->transfer) {
|
||||
throw new BadRequestHttpException('Cannot restore backup while server is being transferred.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backup for restoration
|
||||
*/
|
||||
private function validateBackupForRestore(Backup $backup): void
|
||||
{
|
||||
if (!$backup->is_successful && is_null($backup->completed_at)) {
|
||||
throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.');
|
||||
}
|
||||
|
||||
// Additional safety check for backup integrity
|
||||
if (!$backup->is_successful) {
|
||||
throw new BadRequestHttpException('Cannot restore a failed backup.');
|
||||
}
|
||||
|
||||
if (is_null($backup->completed_at)) {
|
||||
throw new BadRequestHttpException('Cannot restore backup that is still in progress.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class NetworkAllocationController extends ClientApiController
|
||||
{
|
||||
$original = $allocation->notes;
|
||||
|
||||
$allocation->forceFill(['notes' => $request->input('notes')])->save();
|
||||
$allocation->forceFill(['notes' => $request->input('notes')])->skipValidation()->save();
|
||||
|
||||
if ($original !== $allocation->notes) {
|
||||
Activity::event('server:allocation.notes')
|
||||
|
||||
@@ -2,37 +2,45 @@
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Response;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Pterodactyl\Facades\Activity;
|
||||
use Pterodactyl\Repositories\Eloquent\ServerRepository;
|
||||
use Pterodactyl\Services\Servers\ReinstallServerService;
|
||||
use Pterodactyl\Services\Backups\InitiateBackupService;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Pterodactyl\Services\ServerOperations\ServerOperationService;
|
||||
use Pterodactyl\Services\ServerOperations\ServerStateValidationService;
|
||||
use Pterodactyl\Services\ServerOperations\EggChangeService;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RevertDockerImageRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetEggRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\PreviewEggRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ApplyEggChangeRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
|
||||
use Pterodactyl\Services\Servers\StartupModificationService;
|
||||
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
|
||||
|
||||
class SettingsController extends ClientApiController
|
||||
{
|
||||
/**
|
||||
* SettingsController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private ServerRepository $repository,
|
||||
private ReinstallServerService $reinstallServerService,
|
||||
private StartupModificationService $startupModificationService,
|
||||
private InitiateBackupService $backupService,
|
||||
private DaemonFileRepository $fileRepository,
|
||||
private ServerOperationService $operationService,
|
||||
private ServerStateValidationService $validationService,
|
||||
private EggChangeService $eggChangeService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a server.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function rename(RenameServerRequest $request, Server $server): JsonResponse
|
||||
{
|
||||
$name = $request->input('name');
|
||||
@@ -57,25 +65,13 @@ class SettingsController extends ClientApiController
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinstalls the server on the daemon.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function reinstall(ReinstallServerRequest $request, Server $server): JsonResponse
|
||||
{
|
||||
$this->reinstallServerService->handle($server);
|
||||
|
||||
Activity::event('server:reinstall')->log();
|
||||
|
||||
return new JsonResponse([], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the Docker image in use by the server.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse
|
||||
{
|
||||
if (!in_array($request->input('docker_image'), array_values($server->egg->docker_images))) {
|
||||
@@ -94,44 +90,150 @@ class SettingsController extends ClientApiController
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Startup Command
|
||||
*/
|
||||
public function revertDockerImage(RevertDockerImageRequest $request, Server $server): JsonResponse
|
||||
{
|
||||
$server->validateCurrentState();
|
||||
|
||||
$original = $server->image;
|
||||
$defaultImage = $server->getDefaultDockerImage();
|
||||
|
||||
if (empty($defaultImage)) {
|
||||
throw new BadRequestHttpException('No default docker image available for this server\'s egg.');
|
||||
}
|
||||
|
||||
$server->forceFill(['image' => $defaultImage])->saveOrFail();
|
||||
|
||||
Activity::event('server:startup.image.reverted')
|
||||
->property([
|
||||
'old' => $original,
|
||||
'new' => $defaultImage,
|
||||
'reverted_to_egg_default' => true,
|
||||
])
|
||||
->log();
|
||||
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
private function resetStartupCommand(Server $server): JsonResponse
|
||||
{
|
||||
$server->startup = $server->egg->startup;
|
||||
$server->save();
|
||||
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the egg for a server.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function changeEgg(SetEggRequest $request, Server $server): JsonResponse {
|
||||
public function changeEgg(SetEggRequest $request, Server $server): JsonResponse
|
||||
{
|
||||
$eggId = $request->input('egg_id');
|
||||
$nestId = $request->input('nest_id');
|
||||
$originalEggId = $server->egg_id;
|
||||
$originalNestId = $server->nest_id;
|
||||
|
||||
// Check if the new Egg and Nest IDs are different from the current ones
|
||||
|
||||
if ($originalEggId !== $eggId || $originalNestId !== $nestId) {
|
||||
// Update the server's Egg and Nest IDs
|
||||
$server->egg_id = $eggId;
|
||||
$server->nest_id = $nestId;
|
||||
$server->save();
|
||||
|
||||
// Log an activity event for the Egg change
|
||||
|
||||
Activity::event('server:settings.egg')
|
||||
->property(['original_egg_id' => $originalEggId, 'new_egg_id' => $eggId, 'original_nest_id' => $originalNestId, 'new_nest_id' => $nestId])
|
||||
->log();
|
||||
|
||||
// Reset the server's startup command
|
||||
$this->resetStartupCommand($server);
|
||||
}
|
||||
|
||||
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
public function previewEggChange(PreviewEggRequest $request, Server $server): JsonResponse
|
||||
{
|
||||
try {
|
||||
$eggId = $request->input('egg_id');
|
||||
$nestId = $request->input('nest_id');
|
||||
|
||||
$previewData = $this->eggChangeService->previewEggChange($server, $eggId, $nestId);
|
||||
|
||||
// Log the preview action
|
||||
Activity::event('server:settings.egg-preview')
|
||||
->property([
|
||||
'current_egg_id' => $server->egg_id,
|
||||
'preview_egg_id' => $eggId,
|
||||
'preview_nest_id' => $nestId,
|
||||
])
|
||||
->log();
|
||||
|
||||
return new JsonResponse($previewData);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to preview egg change', [
|
||||
'server_id' => $server->id,
|
||||
'egg_id' => $request->input('egg_id'),
|
||||
'nest_id' => $request->input('nest_id'),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply egg configuration changes asynchronously.
|
||||
* This dispatches a background job to handle the complete egg change process.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function applyEggChange(ApplyEggChangeRequest $request, Server $server): JsonResponse
|
||||
{
|
||||
try {
|
||||
$eggId = $request->input('egg_id');
|
||||
$nestId = $request->input('nest_id');
|
||||
$dockerImage = $request->input('docker_image');
|
||||
$startupCommand = $request->input('startup_command');
|
||||
$environment = $request->input('environment', []);
|
||||
$shouldBackup = $request->input('should_backup', false);
|
||||
$shouldWipe = $request->input('should_wipe', false);
|
||||
|
||||
$result = $this->eggChangeService->applyEggChangeAsync(
|
||||
$server,
|
||||
$request->user(),
|
||||
$eggId,
|
||||
$nestId,
|
||||
$dockerImage,
|
||||
$startupCommand,
|
||||
$environment,
|
||||
$shouldBackup,
|
||||
$shouldWipe
|
||||
);
|
||||
|
||||
Activity::event('server:software.change-queued')
|
||||
->property([
|
||||
'operation_id' => $result['operation_id'],
|
||||
'from_egg' => $server->egg_id,
|
||||
'to_egg' => $eggId,
|
||||
'should_backup' => $shouldBackup,
|
||||
'should_wipe' => $shouldWipe,
|
||||
])
|
||||
->log();
|
||||
|
||||
return new JsonResponse($result, Response::HTTP_ACCEPTED);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to apply egg change', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function getOperationStatus(Server $server, string $operationId): JsonResponse
|
||||
{
|
||||
$operation = $this->operationService->getOperation($server, $operationId);
|
||||
return new JsonResponse($this->operationService->formatOperationResponse($operation));
|
||||
}
|
||||
|
||||
public function getServerOperations(Server $server): JsonResponse
|
||||
{
|
||||
$operations = $this->operationService->getServerOperations($server);
|
||||
return new JsonResponse(['operations' => $operations]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Facades\Activity;
|
||||
use Pterodactyl\Services\Servers\StartupCommandService;
|
||||
use Pterodactyl\Services\Servers\StartupCommandUpdateService;
|
||||
use Pterodactyl\Repositories\Eloquent\ServerVariableRepository;
|
||||
use Pterodactyl\Transformers\Api\Client\EggVariableTransformer;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\GetStartupRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupCommandRequest;
|
||||
|
||||
class StartupController extends ClientApiController
|
||||
{
|
||||
@@ -19,6 +21,7 @@ class StartupController extends ClientApiController
|
||||
*/
|
||||
public function __construct(
|
||||
private StartupCommandService $startupCommandService,
|
||||
private StartupCommandUpdateService $startupCommandUpdateService,
|
||||
private ServerVariableRepository $repository,
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -96,4 +99,59 @@ class StartupController extends ClientApiController
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the startup command for a server.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function updateCommand(UpdateStartupCommandRequest $request, Server $server): array
|
||||
{
|
||||
$this->startupCommandUpdateService->handle($server, $request->input('startup'));
|
||||
|
||||
$startup = $this->startupCommandService->handle($server);
|
||||
|
||||
return $this->fractal->collection(
|
||||
$server->variables()->where('user_viewable', true)->get()
|
||||
)
|
||||
->transformWith($this->getTransformer(EggVariableTransformer::class))
|
||||
->addMeta([
|
||||
'startup_command' => $startup,
|
||||
'docker_images' => $server->egg->docker_images,
|
||||
'raw_startup_command' => $server->startup,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default startup command for the server's egg.
|
||||
*/
|
||||
public function getDefaultCommand(GetStartupRequest $request, Server $server): array
|
||||
{
|
||||
return [
|
||||
'default_startup_command' => $server->egg->startup,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a startup command with variables for live preview.
|
||||
*/
|
||||
public function processCommand(GetStartupRequest $request, Server $server): array
|
||||
{
|
||||
$command = $request->input('command', $server->startup);
|
||||
|
||||
// Temporarily update the server's startup command for processing
|
||||
$originalStartup = $server->startup;
|
||||
$server->startup = $command;
|
||||
|
||||
$processedCommand = $this->startupCommandService->handle($server, false);
|
||||
|
||||
// Restore original startup command
|
||||
$server->startup = $originalStartup;
|
||||
|
||||
return [
|
||||
'processed_command' => $processedCommand,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
207
app/Http/Controllers/Api/Client/Servers/SubdomainController.php
Normal file
207
app/Http/Controllers/Api/Client/Servers/SubdomainController.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Pterodactyl\Models\Domain;
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Pterodactyl\Models\ServerSubdomain;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Pterodactyl\Services\Subdomain\SubdomainManagementService;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Subdomain\CreateSubdomainRequest;
|
||||
use Pterodactyl\Transformers\Api\Client\ServerSubdomainTransformer;
|
||||
|
||||
class SubdomainController extends ClientApiController
|
||||
{
|
||||
public function __construct(private SubdomainManagementService $subdomainService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subdomain information for a server.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$server = $request->attributes->get('server');
|
||||
|
||||
$this->authorize(Permission::ACTION_ALLOCATION_READ, $server);
|
||||
|
||||
try {
|
||||
// Check if server supports subdomains
|
||||
$feature = $this->subdomainService->getServerSubdomainFeature($server);
|
||||
if (!$feature) {
|
||||
return response()->json([
|
||||
'supported' => false,
|
||||
'message' => 'This server does not support subdomains.'
|
||||
]);
|
||||
}
|
||||
|
||||
$currentSubdomain = $server->activeSubdomain;
|
||||
$availableDomains = $this->subdomainService->getAvailableDomains();
|
||||
|
||||
return response()->json([
|
||||
'supported' => true,
|
||||
'current_subdomain' => $currentSubdomain ? [
|
||||
'object' => 'server_subdomain',
|
||||
'attributes' => [
|
||||
'subdomain' => $currentSubdomain->subdomain,
|
||||
'domain' => $currentSubdomain->domain->name,
|
||||
'domain_id' => $currentSubdomain->domain_id,
|
||||
'full_domain' => $currentSubdomain->full_domain,
|
||||
'is_active' => $currentSubdomain->is_active,
|
||||
]
|
||||
] : null,
|
||||
'available_domains' => $availableDomains,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => 'Unable to retrieve subdomain information.'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subdomain for the server, or replace an existing one.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(CreateSubdomainRequest $request): JsonResponse
|
||||
{
|
||||
$server = $request->attributes->get('server');
|
||||
|
||||
$this->authorize(Permission::ACTION_ALLOCATION_CREATE, $server);
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
try {
|
||||
// Get ALL active subdomains for this server (more than one should be impossible, but PHP makes me angry)
|
||||
$existingSubdomains = $server->subdomains()->where('is_active', true)->get();
|
||||
|
||||
$domain = Domain::where('id', $data['domain_id'])
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$domain) {
|
||||
return response()->json([
|
||||
'error' => 'Selected domain is not available.'
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($existingSubdomains->isNotEmpty()) {
|
||||
foreach ($existingSubdomains as $existingSubdomain) {
|
||||
try {
|
||||
$this->subdomainService->deleteSubdomain($existingSubdomain);
|
||||
Log::info("Deleted existing subdomain {$existingSubdomain->full_domain} during replacement for server {$server->id}");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to delete existing subdomain {$existingSubdomain->full_domain} during replacement: {$e->getMessage()}");
|
||||
return response()->json([
|
||||
'error' => 'Failed to remove existing subdomain. Please try again.'
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
// Refresh server relationship to ensure we get updated data
|
||||
$server->refresh();
|
||||
}
|
||||
|
||||
$serverSubdomain = $this->subdomainService->createSubdomain(
|
||||
$server,
|
||||
$domain,
|
||||
$data['subdomain']
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => $existingSubdomains->isNotEmpty() ? 'Subdomain replaced successfully.' : 'Subdomain created successfully.',
|
||||
'subdomain' => [
|
||||
'object' => 'server_subdomain',
|
||||
'attributes' => [
|
||||
'subdomain' => $serverSubdomain->subdomain,
|
||||
'domain' => $serverSubdomain->domain->name,
|
||||
'domain_id' => $serverSubdomain->domain_id,
|
||||
'full_domain' => $serverSubdomain->full_domain,
|
||||
'is_active' => $serverSubdomain->is_active,
|
||||
],
|
||||
]
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => $existingSubdomains->isNotEmpty() ? 'Failed to replace subdomain.' : 'Failed to create subdomain.'
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the server's subdomain.
|
||||
*/
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$server = $request->attributes->get('server');
|
||||
|
||||
$this->authorize(Permission::ACTION_ALLOCATION_DELETE, $server);
|
||||
|
||||
try {
|
||||
$serverSubdomains = $server->subdomains()->where('is_active', true)->get();
|
||||
if ($serverSubdomains->isEmpty()) {
|
||||
return response()->json([
|
||||
'error' => 'Server does not have any active subdomains.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
foreach ($serverSubdomains as $serverSubdomain) {
|
||||
$this->subdomainService->deleteSubdomain($serverSubdomain);
|
||||
Log::info("Deleted subdomain {$serverSubdomain->full_domain} for server {$server->id}");
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Subdomain(s) deleted successfully.'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => 'Failed to delete subdomain(s).'
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a subdomain is available.
|
||||
*/
|
||||
public function checkAvailability(Request $request): JsonResponse
|
||||
{
|
||||
$server = $request->attributes->get('server');
|
||||
|
||||
$this->authorize(Permission::ACTION_ALLOCATION_READ, $server);
|
||||
|
||||
$request->validate([
|
||||
'subdomain' => 'required|string|min:1|max:63|regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/',
|
||||
'domain_id' => 'required|integer|exists:domains,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
$domain = Domain::where('id', $request->input('domain_id'))
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$domain) {
|
||||
return response()->json([
|
||||
'error' => 'Selected domain is not available.'
|
||||
], 422);
|
||||
}
|
||||
|
||||
$subdomain = strtolower(trim($request->input('subdomain')));
|
||||
$availabilityResult = $this->subdomainService->checkSubdomainAvailability($subdomain, $domain);
|
||||
|
||||
return response()->json([
|
||||
'available' => $availabilityResult['available'],
|
||||
'message' => $availabilityResult['message']
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => 'Unable to check subdomain availability.'
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class WebsocketController extends ClientApiController
|
||||
])
|
||||
->handle($node, $user->id . $server->uuid);
|
||||
|
||||
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getConnectionAddress());
|
||||
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getBrowserConnectionAddress());
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
|
||||
@@ -14,124 +14,124 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class BackupRemoteUploadController extends Controller
|
||||
{
|
||||
public const DEFAULT_MAX_PART_SIZE = 5 * 1024 * 1024 * 1024;
|
||||
public const DEFAULT_MAX_PART_SIZE = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* BackupRemoteUploadController constructor.
|
||||
*/
|
||||
public function __construct(private BackupManager $backupManager)
|
||||
{
|
||||
/**
|
||||
* BackupRemoteUploadController constructor.
|
||||
*/
|
||||
public function __construct(private BackupManager $backupManager)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the required presigned urls to upload a backup to S3 cloud storage.
|
||||
*
|
||||
* @throws \Exception
|
||||
* @throws \Throwable
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public function __invoke(Request $request, string $backup): JsonResponse
|
||||
{
|
||||
// Get the node associated with the request.
|
||||
/** @var \Pterodactyl\Models\Node $node */
|
||||
$node = $request->attributes->get('node');
|
||||
|
||||
// Get the size query parameter.
|
||||
$size = (int) $request->query('size');
|
||||
if (empty($size)) {
|
||||
throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the required presigned urls to upload a backup to S3 cloud storage.
|
||||
*
|
||||
* @throws \Exception
|
||||
* @throws \Throwable
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public function __invoke(Request $request, string $backup): JsonResponse
|
||||
{
|
||||
// Get the node associated with the request.
|
||||
/** @var \Pterodactyl\Models\Node $node */
|
||||
$node = $request->attributes->get('node');
|
||||
/** @var Backup $model */
|
||||
$model = Backup::query()
|
||||
->where('uuid', $backup)
|
||||
->firstOrFail();
|
||||
|
||||
// Get the size query parameter.
|
||||
$size = (int) $request->query('size');
|
||||
if (empty($size)) {
|
||||
throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.');
|
||||
}
|
||||
|
||||
/** @var Backup $model */
|
||||
$model = Backup::query()
|
||||
->where('uuid', $backup)
|
||||
->firstOrFail();
|
||||
|
||||
// Check that the backup is "owned" by the node making the request. This avoids other nodes
|
||||
// from messing with backups that they don't own.
|
||||
/** @var \Pterodactyl\Models\Server $server */
|
||||
$server = $model->server;
|
||||
if ($server->node_id !== $node->id) {
|
||||
throw new HttpForbiddenException('You do not have permission to access that backup.');
|
||||
}
|
||||
|
||||
// Prevent backups that have already been completed from trying to
|
||||
// be uploaded again.
|
||||
if (!is_null($model->completed_at)) {
|
||||
throw new ConflictHttpException('This backup is already in a completed state.');
|
||||
}
|
||||
|
||||
// Ensure we are using the S3 adapter.
|
||||
$adapter = $this->backupManager->adapter();
|
||||
if (!$adapter instanceof S3Filesystem) {
|
||||
throw new BadRequestHttpException('The configured backup adapter is not an S3 compatible adapter.');
|
||||
}
|
||||
|
||||
// The path where backup will be uploaded to
|
||||
$path = sprintf('%s/%s.tar.gz', $model->server->uuid, $model->uuid);
|
||||
|
||||
// Get the S3 client
|
||||
$client = $adapter->getClient();
|
||||
$expires = CarbonImmutable::now()->addMinutes(config('backups.presigned_url_lifespan', 60));
|
||||
|
||||
// Params for generating the presigned urls
|
||||
$params = [
|
||||
'Bucket' => $adapter->getBucket(),
|
||||
'Key' => $path,
|
||||
'ContentType' => 'application/x-gzip',
|
||||
];
|
||||
|
||||
$storageClass = config('backups.disks.s3.storage_class');
|
||||
if (!is_null($storageClass)) {
|
||||
$params['StorageClass'] = $storageClass;
|
||||
}
|
||||
|
||||
// Execute the CreateMultipartUpload request
|
||||
$result = $client->execute($client->getCommand('CreateMultipartUpload', $params));
|
||||
|
||||
// Get the UploadId from the CreateMultipartUpload request, this is needed to create
|
||||
// the other presigned urls.
|
||||
$params['UploadId'] = $result->get('UploadId');
|
||||
|
||||
// Retrieve configured part size
|
||||
$maxPartSize = $this->getConfiguredMaxPartSize();
|
||||
|
||||
// Create as many UploadPart presigned urls as needed
|
||||
$parts = [];
|
||||
for ($i = 0; $i < ($size / $maxPartSize); ++$i) {
|
||||
$parts[] = $client->createPresignedRequest(
|
||||
$client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])),
|
||||
$expires
|
||||
)->getUri()->__toString();
|
||||
}
|
||||
|
||||
// Set the upload_id on the backup in the database.
|
||||
$model->update(['upload_id' => $params['UploadId']]);
|
||||
|
||||
return new JsonResponse([
|
||||
'parts' => $parts,
|
||||
'part_size' => $maxPartSize,
|
||||
]);
|
||||
// Check that the backup is "owned" by the node making the request. This avoids other nodes
|
||||
// from messing with backups that they don't own.
|
||||
/** @var \Pterodactyl\Models\Server $server */
|
||||
$server = $model->server;
|
||||
if ($server->node_id !== $node->id) {
|
||||
throw new HttpForbiddenException('You do not have permission to access that backup.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured maximum size of a single part in the multipart upload.
|
||||
*
|
||||
* The function tries to retrieve a configured value from the configuration.
|
||||
* If no value is specified, a fallback value will be used.
|
||||
*
|
||||
* Note if the received config cannot be converted to int (0), is zero or is negative,
|
||||
* the fallback value will be used too.
|
||||
*
|
||||
* The fallback value is {@see BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE}.
|
||||
*/
|
||||
private function getConfiguredMaxPartSize(): int
|
||||
{
|
||||
$maxPartSize = (int) config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE);
|
||||
if ($maxPartSize <= 0) {
|
||||
$maxPartSize = self::DEFAULT_MAX_PART_SIZE;
|
||||
}
|
||||
|
||||
return $maxPartSize;
|
||||
// Prevent backups that have already been completed from trying to
|
||||
// be uploaded again.
|
||||
if (!is_null($model->completed_at)) {
|
||||
throw new ConflictHttpException('This backup is already in a completed state.');
|
||||
}
|
||||
|
||||
// Ensure we are using the S3 adapter.
|
||||
$adapter = $this->backupManager->adapter();
|
||||
if (!$adapter instanceof S3Filesystem) {
|
||||
throw new BadRequestHttpException('The configured backup adapter is not an S3 compatible adapter.');
|
||||
}
|
||||
|
||||
// The path where backup will be uploaded to
|
||||
$path = sprintf('%s/%s.tar.gz', $model->server->uuid, $model->uuid);
|
||||
|
||||
// Get the S3 client
|
||||
$client = $adapter->getClient();
|
||||
$expires = CarbonImmutable::now()->addMinutes((int) config('backups.presigned_url_lifespan', 60));
|
||||
|
||||
// Params for generating the presigned urls
|
||||
$params = [
|
||||
'Bucket' => $adapter->getBucket(),
|
||||
'Key' => $path,
|
||||
'ContentType' => 'application/x-gzip',
|
||||
];
|
||||
|
||||
$storageClass = config('backups.disks.s3.storage_class');
|
||||
if (!is_null($storageClass)) {
|
||||
$params['StorageClass'] = $storageClass;
|
||||
}
|
||||
|
||||
// Execute the CreateMultipartUpload request
|
||||
$result = $client->execute($client->getCommand('CreateMultipartUpload', $params));
|
||||
|
||||
// Get the UploadId from the CreateMultipartUpload request, this is needed to create
|
||||
// the other presigned urls.
|
||||
$params['UploadId'] = $result->get('UploadId');
|
||||
|
||||
// Retrieve configured part size
|
||||
$maxPartSize = $this->getConfiguredMaxPartSize();
|
||||
|
||||
// Create as many UploadPart presigned urls as needed
|
||||
$parts = [];
|
||||
for ($i = 0; $i < ($size / $maxPartSize); ++$i) {
|
||||
$parts[] = $client->createPresignedRequest(
|
||||
$client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])),
|
||||
$expires
|
||||
)->getUri()->__toString();
|
||||
}
|
||||
|
||||
// Set the upload_id on the backup in the database.
|
||||
$model->update(['upload_id' => $params['UploadId']]);
|
||||
|
||||
return new JsonResponse([
|
||||
'parts' => $parts,
|
||||
'part_size' => $maxPartSize,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured maximum size of a single part in the multipart upload.
|
||||
*
|
||||
* The function tries to retrieve a configured value from the configuration.
|
||||
* If no value is specified, a fallback value will be used.
|
||||
*
|
||||
* Note if the received config cannot be converted to int (0), is zero or is negative,
|
||||
* the fallback value will be used too.
|
||||
*
|
||||
* The fallback value is {@see BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE}.
|
||||
*/
|
||||
private function getConfiguredMaxPartSize(): int
|
||||
{
|
||||
$maxPartSize = (int) config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE);
|
||||
if ($maxPartSize <= 0) {
|
||||
$maxPartSize = self::DEFAULT_MAX_PART_SIZE;
|
||||
}
|
||||
|
||||
return $maxPartSize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,9 +134,21 @@ class BackupStatusController extends Controller
|
||||
];
|
||||
|
||||
$client = $adapter->getClient();
|
||||
|
||||
if (!$successful) {
|
||||
$client->execute($client->getCommand('AbortMultipartUpload', $params));
|
||||
|
||||
try {
|
||||
$client->execute($client->getCommand('AbortMultipartUpload', $params));
|
||||
\Log::info('Aborted multipart upload for failed backup', [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'upload_id' => $backup->upload_id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Failed to abort multipart upload', [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'upload_id' => $backup->upload_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,17 +157,56 @@ class BackupStatusController extends Controller
|
||||
'Parts' => [],
|
||||
];
|
||||
|
||||
if (is_null($parts)) {
|
||||
$params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts'];
|
||||
} else {
|
||||
foreach ($parts as $part) {
|
||||
$params['MultipartUpload']['Parts'][] = [
|
||||
'ETag' => $part['etag'],
|
||||
'PartNumber' => $part['part_number'],
|
||||
];
|
||||
try {
|
||||
if (is_null($parts)) {
|
||||
$listPartsResult = $client->execute($client->getCommand('ListParts', $params));
|
||||
$params['MultipartUpload']['Parts'] = $listPartsResult['Parts'] ?? [];
|
||||
} else {
|
||||
foreach ($parts as $part) {
|
||||
// Validate part data
|
||||
if (!isset($part['etag']) || !isset($part['part_number'])) {
|
||||
throw new DisplayException('Invalid part data provided for multipart upload completion.');
|
||||
}
|
||||
|
||||
$params['MultipartUpload']['Parts'][] = [
|
||||
'ETag' => $part['etag'],
|
||||
'PartNumber' => (int) $part['part_number'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$client->execute($client->getCommand('CompleteMultipartUpload', $params));
|
||||
// Ensure we have parts to complete
|
||||
if (empty($params['MultipartUpload']['Parts'])) {
|
||||
throw new DisplayException('No parts found for multipart upload completion.');
|
||||
}
|
||||
|
||||
$client->execute($client->getCommand('CompleteMultipartUpload', $params));
|
||||
|
||||
\Log::info('Successfully completed multipart upload', [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'upload_id' => $backup->upload_id,
|
||||
'parts_count' => count($params['MultipartUpload']['Parts']),
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to complete multipart upload', [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'upload_id' => $backup->upload_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Try to abort the upload to clean up
|
||||
try {
|
||||
$client->execute($client->getCommand('AbortMultipartUpload', $params));
|
||||
} catch (\Exception $abortException) {
|
||||
\Log::warning('Failed to abort multipart upload after completion failure', [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'upload_id' => $backup->upload_id,
|
||||
'abort_error' => $abortException->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Base;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Translation\Translator;
|
||||
use Illuminate\Contracts\Translation\Loader;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Pterodactyl\Http\Requests\Base\LocaleRequest;
|
||||
|
||||
class LocaleController extends Controller
|
||||
{
|
||||
@@ -20,20 +20,11 @@ class LocaleController extends Controller
|
||||
/**
|
||||
* Returns translation data given a specific locale and namespace.
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
public function __invoke(LocaleRequest $request): JsonResponse
|
||||
{
|
||||
$locales = explode(' ', $request->input('locale') ?? '');
|
||||
$namespaces = explode(' ', $request->input('namespace') ?? '');
|
||||
|
||||
$response = [];
|
||||
foreach ($locales as $locale) {
|
||||
$response[$locale] = [];
|
||||
foreach ($namespaces as $namespace) {
|
||||
$response[$locale][$namespace] = $this->i18n(
|
||||
$this->loader->load($locale, str_replace('.', '/', $namespace))
|
||||
);
|
||||
}
|
||||
}
|
||||
$locale = $request->input('locale');
|
||||
$namespace = $request->input('namespace');
|
||||
$response[$locale][$namespace] = $this->i18n($this->loader->load($locale, $namespace));
|
||||
|
||||
return new JsonResponse($response, 200, [
|
||||
// Cache this in the browser for an hour, and allow the browser to use a stale
|
||||
|
||||
152
app/Http/Controllers/Base/SystemStatusController.php
Normal file
152
app/Http/Controllers/Base/SystemStatusController.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Base;
|
||||
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SystemStatusController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get system metrics and status
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$metrics = Cache::remember('system_metrics', 60, function () {
|
||||
return [
|
||||
'status' => 'running',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'metrics' => [
|
||||
'uptime' => $this->getUptime(),
|
||||
'memory' => $this->getMemoryUsage(),
|
||||
'cpu' => $this->getCpuUsage(),
|
||||
'disk' => $this->getDiskUsage(),
|
||||
],
|
||||
'system' => [
|
||||
'php_version' => PHP_VERSION,
|
||||
'os' => php_uname(),
|
||||
'hostname' => gethostname(),
|
||||
'load_average' => sys_getloadavg(),
|
||||
]
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($metrics);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Failed to retrieve system metrics',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function getMemoryUsage(): array
|
||||
{
|
||||
if (PHP_OS_FAMILY === 'Darwin') {
|
||||
$memory = shell_exec('vm_stat');
|
||||
if (!$memory) {
|
||||
throw new \RuntimeException('Failed to execute vm_stat command');
|
||||
}
|
||||
|
||||
// Parse memory stats more reliably
|
||||
$stats = [];
|
||||
foreach (explode("\n", $memory) as $line) {
|
||||
if (preg_match('/Pages\s+([^:]+):\s+(\d+)/', $line, $matches)) {
|
||||
$stats[strtolower($matches[1])] = (int) $matches[2];
|
||||
}
|
||||
}
|
||||
|
||||
$page_size = 4096; // Default page size for macOS
|
||||
|
||||
$total_memory = $this->getTotalMemoryMac();
|
||||
$free_memory = ($stats['free'] ?? 0) * $page_size;
|
||||
$used_memory = $total_memory - $free_memory;
|
||||
|
||||
return [
|
||||
'total' => $total_memory,
|
||||
'used' => $used_memory,
|
||||
'free' => $free_memory,
|
||||
'page_size' => $page_size
|
||||
];
|
||||
}
|
||||
|
||||
// Linux memory calculation
|
||||
$memory = shell_exec('free -b');
|
||||
if (!$memory) {
|
||||
throw new \RuntimeException('Failed to execute free command');
|
||||
}
|
||||
|
||||
if (!preg_match('/Mem:\s+(\d+)\s+(\d+)\s+(\d+)/', $memory, $matches)) {
|
||||
throw new \RuntimeException('Failed to parse memory information');
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => (int) $matches[1],
|
||||
'used' => (int) $matches[2],
|
||||
'free' => (int) $matches[3]
|
||||
];
|
||||
}
|
||||
|
||||
private function getTotalMemoryMac(): int
|
||||
{
|
||||
$memory = shell_exec('sysctl hw.memsize');
|
||||
if (!$memory || !preg_match('/hw.memsize: (\d+)/', $memory, $matches)) {
|
||||
throw new \RuntimeException('Failed to get total memory size');
|
||||
}
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
private function getCpuUsage(): float
|
||||
{
|
||||
if (PHP_OS_FAMILY === 'Darwin') {
|
||||
$cmd = "top -l 1 | grep -E '^CPU' | awk '{print $3}' | cut -d'%' -f1";
|
||||
} else {
|
||||
$cmd = "top -bn1 | grep 'Cpu(s)' | awk '{print $2 + $4}'";
|
||||
}
|
||||
|
||||
$usage = shell_exec($cmd);
|
||||
if ($usage === null) {
|
||||
throw new \RuntimeException('Failed to get CPU usage');
|
||||
}
|
||||
|
||||
return (float) $usage;
|
||||
}
|
||||
|
||||
private function getDiskUsage(): array
|
||||
{
|
||||
$total = disk_total_space('/');
|
||||
$free = disk_free_space('/');
|
||||
|
||||
if ($total === false || $free === false) {
|
||||
throw new \RuntimeException('Failed to get disk space information');
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'free' => $free,
|
||||
'used' => $total - $free
|
||||
];
|
||||
}
|
||||
|
||||
private function getUptime(): int
|
||||
{
|
||||
if (PHP_OS_FAMILY === 'Darwin') {
|
||||
$uptime = shell_exec('sysctl -n kern.boottime');
|
||||
if (!$uptime || !preg_match('/sec = (\d+)/', $uptime, $matches)) {
|
||||
throw new \RuntimeException('Failed to get system uptime');
|
||||
}
|
||||
return time() - (int) $matches[1];
|
||||
}
|
||||
|
||||
$uptime = @file_get_contents('/proc/uptime');
|
||||
if ($uptime === false) {
|
||||
throw new \RuntimeException('Failed to read uptime file');
|
||||
}
|
||||
|
||||
return (int) floatval($uptime);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ use Illuminate\Session\Middleware\StartSession;
|
||||
use Pterodactyl\Http\Middleware\EncryptCookies;
|
||||
use Pterodactyl\Http\Middleware\Api\IsValidJson;
|
||||
use Pterodactyl\Http\Middleware\VerifyCsrfToken;
|
||||
use Pterodactyl\Http\Middleware\VerifyReCaptcha;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Pterodactyl\Http\Middleware\LanguageMiddleware;
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
@@ -36,66 +35,66 @@ use Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser;
|
||||
|
||||
class Kernel extends HttpKernel
|
||||
{
|
||||
/**
|
||||
* The application's global HTTP middleware stack.
|
||||
*/
|
||||
protected $middleware = [
|
||||
TrustProxies::class,
|
||||
HandleCors::class,
|
||||
PreventRequestsDuringMaintenance::class,
|
||||
ValidatePostSize::class,
|
||||
TrimStrings::class,
|
||||
ConvertEmptyStringsToNull::class,
|
||||
];
|
||||
/**
|
||||
* The application's global HTTP middleware stack.
|
||||
*/
|
||||
protected $middleware = [
|
||||
TrustProxies::class,
|
||||
HandleCors::class,
|
||||
PreventRequestsDuringMaintenance::class,
|
||||
ValidatePostSize::class,
|
||||
TrimStrings::class,
|
||||
ConvertEmptyStringsToNull::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware groups.
|
||||
*/
|
||||
protected $middlewareGroups = [
|
||||
'web' => [
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
LanguageMiddleware::class,
|
||||
],
|
||||
'api' => [
|
||||
EnsureStatefulRequests::class,
|
||||
'auth:sanctum',
|
||||
IsValidJson::class,
|
||||
TrackAPIKey::class,
|
||||
RequireTwoFactorAuthentication::class,
|
||||
AuthenticateIPAccess::class,
|
||||
],
|
||||
'application-api' => [
|
||||
SubstituteBindings::class,
|
||||
AuthenticateApplicationUser::class,
|
||||
],
|
||||
'client-api' => [
|
||||
SubstituteClientBindings::class,
|
||||
RequireClientApiKey::class,
|
||||
],
|
||||
'daemon' => [
|
||||
SubstituteBindings::class,
|
||||
DaemonAuthenticate::class,
|
||||
],
|
||||
];
|
||||
/**
|
||||
* The application's route middleware groups.
|
||||
*/
|
||||
protected $middlewareGroups = [
|
||||
'web' => [
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
LanguageMiddleware::class,
|
||||
],
|
||||
'api' => [
|
||||
EnsureStatefulRequests::class,
|
||||
'auth:sanctum',
|
||||
IsValidJson::class,
|
||||
TrackAPIKey::class,
|
||||
RequireTwoFactorAuthentication::class,
|
||||
AuthenticateIPAccess::class,
|
||||
],
|
||||
'application-api' => [
|
||||
SubstituteBindings::class,
|
||||
AuthenticateApplicationUser::class,
|
||||
],
|
||||
'client-api' => [
|
||||
SubstituteClientBindings::class,
|
||||
RequireClientApiKey::class,
|
||||
],
|
||||
'daemon' => [
|
||||
SubstituteBindings::class,
|
||||
DaemonAuthenticate::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware.
|
||||
*/
|
||||
protected $middlewareAliases = [
|
||||
'auth' => Authenticate::class,
|
||||
'auth.basic' => AuthenticateWithBasicAuth::class,
|
||||
'auth.session' => AuthenticateSession::class,
|
||||
'guest' => RedirectIfAuthenticated::class,
|
||||
'csrf' => VerifyCsrfToken::class,
|
||||
'throttle' => ThrottleRequests::class,
|
||||
'can' => Authorize::class,
|
||||
'bindings' => SubstituteBindings::class,
|
||||
'recaptcha' => VerifyReCaptcha::class,
|
||||
'node.maintenance' => MaintenanceMiddleware::class,
|
||||
];
|
||||
/**
|
||||
* The application's route middleware.
|
||||
*/
|
||||
protected $middlewareAliases = [
|
||||
'auth' => Authenticate::class,
|
||||
'auth.basic' => AuthenticateWithBasicAuth::class,
|
||||
'auth.session' => AuthenticateSession::class,
|
||||
'guest' => RedirectIfAuthenticated::class,
|
||||
'csrf' => VerifyCsrfToken::class,
|
||||
'throttle' => ThrottleRequests::class,
|
||||
'can' => Authorize::class,
|
||||
'bindings' => SubstituteBindings::class,
|
||||
'node.maintenance' => MaintenanceMiddleware::class,
|
||||
'captcha' => \Pterodactyl\Http\Middleware\VerifyCaptcha::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\ServerOperation;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
|
||||
/**
|
||||
* Middleware to rate limit server operations.
|
||||
*
|
||||
* Prevents concurrent operations on the same server and provides monitoring
|
||||
* of operation attempts for analytics and troubleshooting.
|
||||
*/
|
||||
class ServerOperationRateLimit
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $operationType = 'general')
|
||||
{
|
||||
/** @var Server $server */
|
||||
$server = $request->route('server');
|
||||
$user = $request->user();
|
||||
|
||||
$this->checkActiveOperations($server);
|
||||
$this->logOperationAttempt($server, $user, $operationType);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for active operations on the same server.
|
||||
*/
|
||||
private function checkActiveOperations(Server $server): void
|
||||
{
|
||||
try {
|
||||
if (!$this->tableExists('server_operations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activeOperations = ServerOperation::forServer($server)->active()->count();
|
||||
|
||||
if ($activeOperations > 0) {
|
||||
throw new TooManyRequestsHttpException(
|
||||
300,
|
||||
'Another operation is currently in progress for this server. Please wait for it to complete.'
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Failed to check for active operations', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a database table exists.
|
||||
*/
|
||||
private function tableExists(string $tableName): bool
|
||||
{
|
||||
try {
|
||||
return \Schema::hasTable($tableName);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Failed to check if table exists', [
|
||||
'table' => $tableName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log operation attempt for monitoring.
|
||||
*/
|
||||
private function logOperationAttempt(Server $server, $user, string $operationType): void
|
||||
{
|
||||
Log::info('Server operation attempt', [
|
||||
'server_id' => $server->id,
|
||||
'server_uuid' => $server->uuid,
|
||||
'user_id' => $user->id,
|
||||
'operation_type' => $operationType,
|
||||
'timestamp' => now()->toISOString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
app/Http/Middleware/VerifyCaptcha.php
Normal file
81
app/Http/Middleware/VerifyCaptcha.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Pterodactyl\Services\Captcha\CaptchaManager;
|
||||
use Pterodactyl\Exceptions\DisplayException;
|
||||
|
||||
class VerifyCaptcha
|
||||
{
|
||||
protected CaptchaManager $captcha;
|
||||
|
||||
public function __construct(CaptchaManager $captcha)
|
||||
{
|
||||
$this->captcha = $captcha;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// Skip verification if captcha is not enabled
|
||||
$defaultDriver = $this->captcha->getDefaultDriver();
|
||||
|
||||
Log::info('VerifyCaptcha middleware triggered', [
|
||||
'default_driver' => $defaultDriver,
|
||||
'request_url' => $request->url(),
|
||||
'request_method' => $request->method(),
|
||||
]);
|
||||
|
||||
if ($defaultDriver === 'none') {
|
||||
Log::info('Captcha verification skipped - driver is none');
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Get the captcha response from the request
|
||||
$driver = $this->captcha->driver();
|
||||
$fieldName = $driver->getResponseFieldName();
|
||||
$captchaResponse = $request->input($fieldName);
|
||||
|
||||
Log::info('Captcha verification details', [
|
||||
'driver_name' => $driver->getName(),
|
||||
'field_name' => $fieldName,
|
||||
'response_present' => !empty($captchaResponse),
|
||||
'response_length' => $captchaResponse ? strlen($captchaResponse) : 0,
|
||||
'all_request_keys' => array_keys($request->all()),
|
||||
]);
|
||||
|
||||
if (empty($captchaResponse)) {
|
||||
Log::warning('Captcha verification failed - no response provided', [
|
||||
'field_name' => $fieldName,
|
||||
'request_data' => $request->all(),
|
||||
]);
|
||||
throw new DisplayException('Please complete the captcha verification.');
|
||||
}
|
||||
|
||||
// Verify the captcha response
|
||||
$remoteIp = $request->ip();
|
||||
Log::info('Starting captcha verification', [
|
||||
'remote_ip' => $remoteIp,
|
||||
'response_preview' => substr($captchaResponse, 0, 50) . '...',
|
||||
]);
|
||||
|
||||
$verificationResult = $this->captcha->verify($captchaResponse, $remoteIp);
|
||||
|
||||
Log::info('Captcha verification completed', [
|
||||
'result' => $verificationResult,
|
||||
]);
|
||||
|
||||
if (!$verificationResult) {
|
||||
Log::warning('Captcha verification failed - verification returned false');
|
||||
throw new DisplayException('Captcha verification failed. Please try again.');
|
||||
}
|
||||
|
||||
Log::info('Captcha verification successful - proceeding with request');
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Middleware;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Pterodactyl\Events\Auth\FailedCaptcha;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class VerifyReCaptcha
|
||||
{
|
||||
/**
|
||||
* VerifyReCaptcha constructor.
|
||||
*/
|
||||
public function __construct(private Dispatcher $dispatcher, private Repository $config)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, \Closure $next): mixed
|
||||
{
|
||||
if (!$this->config->get('recaptcha.enabled')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->filled('g-recaptcha-response')) {
|
||||
$client = new Client();
|
||||
$res = $client->post($this->config->get('recaptcha.domain'), [
|
||||
'form_params' => [
|
||||
'secret' => $this->config->get('recaptcha.secret_key'),
|
||||
'response' => $request->input('g-recaptcha-response'),
|
||||
],
|
||||
]);
|
||||
|
||||
if ($res->getStatusCode() === 200) {
|
||||
$result = json_decode($res->getBody());
|
||||
|
||||
if ($result->success && (!$this->config->get('recaptcha.verify_domain') || $this->isResponseVerified($result, $request))) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch(
|
||||
new FailedCaptcha(
|
||||
$request->ip(),
|
||||
!empty($result) ? ($result->hostname ?? null) : null
|
||||
)
|
||||
);
|
||||
|
||||
throw new HttpException(Response::HTTP_BAD_REQUEST, 'Failed to validate reCAPTCHA data.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the response from the recaptcha servers was valid.
|
||||
*/
|
||||
private function isResponseVerified(\stdClass $result, Request $request): bool
|
||||
{
|
||||
if (!$this->config->get('recaptcha.verify_domain')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = parse_url($request->url());
|
||||
|
||||
return $result->hostname === array_get($url, 'host');
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,14 @@ class NodeFormRequest extends AdminFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
if ($this->method() === 'PATCH') {
|
||||
return Node::getRulesForUpdate($this->route()->parameter('node'));
|
||||
$rules = Node::getRulesForUpdate($this->route()->parameter('node'));
|
||||
$rules['internal_fqdn'] = ['nullable', 'string', Fqdn::make('scheme')];
|
||||
return $rules;
|
||||
}
|
||||
|
||||
$data = Node::getRules();
|
||||
$data['fqdn'][] = Fqdn::make('scheme');
|
||||
$data['internal_fqdn'] = ['nullable', 'string', Fqdn::make('scheme')];
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -6,45 +6,39 @@ use Pterodactyl\Http\Requests\Admin\AdminFormRequest;
|
||||
|
||||
class AdvancedSettingsFormRequest extends AdminFormRequest
|
||||
{
|
||||
/**
|
||||
* Return all the rules to apply to this request's data.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'recaptcha:enabled' => 'required|in:true,false',
|
||||
'recaptcha:secret_key' => 'required|string|max:191',
|
||||
'recaptcha:website_key' => 'required|string|max:191',
|
||||
'pterodactyl:guzzle:timeout' => 'required|integer|between:1,60',
|
||||
'pterodactyl:guzzle:connect_timeout' => 'required|integer|between:1,60',
|
||||
'pterodactyl:client_features:allocations:enabled' => 'required|in:true,false',
|
||||
'pterodactyl:client_features:allocations:range_start' => [
|
||||
'nullable',
|
||||
'required_if:pterodactyl:client_features:allocations:enabled,true',
|
||||
'integer',
|
||||
'between:1024,65535',
|
||||
],
|
||||
'pterodactyl:client_features:allocations:range_end' => [
|
||||
'nullable',
|
||||
'required_if:pterodactyl:client_features:allocations:enabled,true',
|
||||
'integer',
|
||||
'between:1024,65535',
|
||||
'gt:pterodactyl:client_features:allocations:range_start',
|
||||
],
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Return all the rules to apply to this request's data.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'pterodactyl:guzzle:timeout' => 'required|integer|between:1,60',
|
||||
'pterodactyl:guzzle:connect_timeout' => 'required|integer|between:1,60',
|
||||
'pterodactyl:client_features:allocations:enabled' => 'required|in:true,false',
|
||||
'pterodactyl:client_features:allocations:range_start' => [
|
||||
'nullable',
|
||||
'required_if:pterodactyl:client_features:allocations:enabled,true',
|
||||
'integer',
|
||||
'between:1024,65535',
|
||||
],
|
||||
'pterodactyl:client_features:allocations:range_end' => [
|
||||
'nullable',
|
||||
'required_if:pterodactyl:client_features:allocations:enabled,true',
|
||||
'integer',
|
||||
'between:1024,65535',
|
||||
'gt:pterodactyl:client_features:allocations:range_start',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'recaptcha:enabled' => 'reCAPTCHA Enabled',
|
||||
'recaptcha:secret_key' => 'reCAPTCHA Secret Key',
|
||||
'recaptcha:website_key' => 'reCAPTCHA Website Key',
|
||||
'pterodactyl:guzzle:timeout' => 'HTTP Request Timeout',
|
||||
'pterodactyl:guzzle:connect_timeout' => 'HTTP Connection Timeout',
|
||||
'pterodactyl:client_features:allocations:enabled' => 'Auto Create Allocations Enabled',
|
||||
'pterodactyl:client_features:allocations:range_start' => 'Starting Port',
|
||||
'pterodactyl:client_features:allocations:range_end' => 'Ending Port',
|
||||
];
|
||||
}
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'pterodactyl:guzzle:timeout' => 'HTTP Request Timeout',
|
||||
'pterodactyl:guzzle:connect_timeout' => 'HTTP Connection Timeout',
|
||||
'pterodactyl:client_features:allocations:enabled' => 'Auto Create Allocations Enabled',
|
||||
'pterodactyl:client_features:allocations:range_start' => 'Starting Port',
|
||||
'pterodactyl:client_features:allocations:range_end' => 'Ending Port',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
105
app/Http/Requests/Admin/Settings/CaptchaSettingsFormRequest.php
Normal file
105
app/Http/Requests/Admin/Settings/CaptchaSettingsFormRequest.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Admin\Settings;
|
||||
|
||||
use Illuminate\Validation\Rule;
|
||||
use Pterodactyl\Http\Requests\Admin\AdminFormRequest;
|
||||
|
||||
class CaptchaSettingsFormRequest extends AdminFormRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'pterodactyl:captcha:provider' => ['required', 'string', Rule::in(['none', 'turnstile', 'hcaptcha', 'recaptcha'])],
|
||||
'pterodactyl:captcha:turnstile:site_key' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
'required_if:pterodactyl:captcha:provider,turnstile',
|
||||
],
|
||||
'pterodactyl:captcha:turnstile:secret_key' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
'required_if:pterodactyl:captcha:provider,turnstile',
|
||||
],
|
||||
'pterodactyl:captcha:hcaptcha:site_key' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
'required_if:pterodactyl:captcha:provider,hcaptcha',
|
||||
],
|
||||
'pterodactyl:captcha:hcaptcha:secret_key' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
'required_if:pterodactyl:captcha:provider,hcaptcha',
|
||||
],
|
||||
'pterodactyl:captcha:recaptcha:site_key' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
'required_if:pterodactyl:captcha:provider,recaptcha',
|
||||
],
|
||||
'pterodactyl:captcha:recaptcha:secret_key' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
'required_if:pterodactyl:captcha:provider,recaptcha',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'pterodactyl:captcha:provider' => 'Captcha Provider',
|
||||
'pterodactyl:captcha:turnstile:site_key' => 'Turnstile Site Key',
|
||||
'pterodactyl:captcha:turnstile:secret_key' => 'Turnstile Secret Key',
|
||||
'pterodactyl:captcha:hcaptcha:site_key' => 'hCaptcha Site Key',
|
||||
'pterodactyl:captcha:hcaptcha:secret_key' => 'hCaptcha Secret Key',
|
||||
'pterodactyl:captcha:recaptcha:site_key' => 'reCAPTCHA Site Key',
|
||||
'pterodactyl:captcha:recaptcha:secret_key' => 'reCAPTCHA Secret Key',
|
||||
];
|
||||
}
|
||||
|
||||
public function normalize(?array $only = null): array
|
||||
{
|
||||
$data = $this->validated();
|
||||
|
||||
// Clear provider-specific settings if provider is 'none'
|
||||
if ($data['pterodactyl:captcha:provider'] === 'none') {
|
||||
$data['pterodactyl:captcha:turnstile:site_key'] = '';
|
||||
$data['pterodactyl:captcha:turnstile:secret_key'] = '';
|
||||
$data['pterodactyl:captcha:hcaptcha:site_key'] = '';
|
||||
$data['pterodactyl:captcha:hcaptcha:secret_key'] = '';
|
||||
$data['pterodactyl:captcha:recaptcha:site_key'] = '';
|
||||
$data['pterodactyl:captcha:recaptcha:secret_key'] = '';
|
||||
}
|
||||
|
||||
// Clear other provider settings when switching providers
|
||||
if ($data['pterodactyl:captcha:provider'] === 'turnstile') {
|
||||
$data['pterodactyl:captcha:hcaptcha:site_key'] = '';
|
||||
$data['pterodactyl:captcha:hcaptcha:secret_key'] = '';
|
||||
$data['pterodactyl:captcha:recaptcha:site_key'] = '';
|
||||
$data['pterodactyl:captcha:recaptcha:secret_key'] = '';
|
||||
} elseif ($data['pterodactyl:captcha:provider'] === 'hcaptcha') {
|
||||
$data['pterodactyl:captcha:turnstile:site_key'] = '';
|
||||
$data['pterodactyl:captcha:turnstile:secret_key'] = '';
|
||||
$data['pterodactyl:captcha:recaptcha:site_key'] = '';
|
||||
$data['pterodactyl:captcha:recaptcha:secret_key'] = '';
|
||||
} elseif ($data['pterodactyl:captcha:provider'] === 'recaptcha') {
|
||||
$data['pterodactyl:captcha:turnstile:site_key'] = '';
|
||||
$data['pterodactyl:captcha:turnstile:secret_key'] = '';
|
||||
$data['pterodactyl:captcha:hcaptcha:site_key'] = '';
|
||||
$data['pterodactyl:captcha:hcaptcha:secret_key'] = '';
|
||||
}
|
||||
|
||||
// Apply the $only filter if provided, similar to parent class
|
||||
if ($only !== null) {
|
||||
return array_intersect_key($data, array_flip($only));
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
82
app/Http/Requests/Admin/Settings/DomainFormRequest.php
Normal file
82
app/Http/Requests/Admin/Settings/DomainFormRequest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Admin\Settings;
|
||||
|
||||
use Pterodactyl\Http\Requests\Admin\AdminFormRequest;
|
||||
|
||||
class DomainFormRequest extends AdminFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$domainId = $this->route('domain')?->id;
|
||||
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:191',
|
||||
'regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/',
|
||||
$domainId ? "unique:domains,name,{$domainId}" : 'unique:domains,name',
|
||||
],
|
||||
'dns_provider' => 'required|string|in:cloudflare',
|
||||
'dns_config' => 'required|array',
|
||||
'dns_config.api_token' => 'required_if:dns_provider,cloudflare|string|min:1',
|
||||
'dns_config.zone_id' => 'sometimes|string|min:1',
|
||||
'is_active' => 'sometimes|boolean',
|
||||
'is_default' => 'sometimes|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'A domain name is required.',
|
||||
'name.regex' => 'The domain name format is invalid.',
|
||||
'name.unique' => 'This domain is already configured.',
|
||||
'dns_provider.required' => 'A DNS provider must be selected.',
|
||||
'dns_provider.in' => 'The selected DNS provider is not supported.',
|
||||
'dns_config.required' => 'DNS configuration is required.',
|
||||
'dns_config.api_token.required_if' => 'API token is required for Cloudflare.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom attributes for validator errors.
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'domain name',
|
||||
'dns_provider' => 'DNS provider',
|
||||
'dns_config.api_token' => 'API token',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Normalize domain name to lowercase
|
||||
if ($this->has('name')) {
|
||||
$this->merge([
|
||||
'name' => strtolower(trim($this->input('name'))),
|
||||
]);
|
||||
}
|
||||
|
||||
// Ensure boolean fields are properly cast
|
||||
foreach (['is_active', 'is_default'] as $field) {
|
||||
if ($this->has($field)) {
|
||||
$this->merge([
|
||||
$field => filter_var($this->input($field), FILTER_VALIDATE_BOOLEAN),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class StoreNodeRequest extends ApplicationApiRequest
|
||||
'name',
|
||||
'location_id',
|
||||
'fqdn',
|
||||
'internal_fqdn',
|
||||
'scheme',
|
||||
'behind_proxy',
|
||||
'maintenance_mode',
|
||||
|
||||
@@ -33,10 +33,12 @@ class StoreServerRequest extends ApplicationApiRequest
|
||||
'environment' => 'present|array',
|
||||
'skip_scripts' => 'sometimes|boolean',
|
||||
'oom_disabled' => 'sometimes|boolean',
|
||||
'exclude_from_resource_calculation' => 'sometimes|boolean',
|
||||
|
||||
// Resource limitations
|
||||
'limits' => 'required|array',
|
||||
'limits.memory' => $rules['memory'],
|
||||
'limits.overhead_memory' => $rules['overhead_memory'],
|
||||
'limits.swap' => $rules['swap'],
|
||||
'limits.disk' => $rules['disk'],
|
||||
'limits.io' => $rules['io'],
|
||||
@@ -82,6 +84,7 @@ class StoreServerRequest extends ApplicationApiRequest
|
||||
'startup' => array_get($data, 'startup'),
|
||||
'environment' => array_get($data, 'environment'),
|
||||
'memory' => array_get($data, 'limits.memory'),
|
||||
'overhead_memory' => array_get($data, 'limits.overhead_memory', 0),
|
||||
'swap' => array_get($data, 'limits.swap'),
|
||||
'disk' => array_get($data, 'limits.disk'),
|
||||
'io' => array_get($data, 'limits.io'),
|
||||
@@ -95,6 +98,7 @@ class StoreServerRequest extends ApplicationApiRequest
|
||||
'allocation_limit' => array_get($data, 'feature_limits.allocations'),
|
||||
'backup_limit' => array_get($data, 'feature_limits.backups'),
|
||||
'oom_disabled' => array_get($data, 'oom_disabled'),
|
||||
'exclude_from_resource_calculation' => array_get($data, 'exclude_from_resource_calculation', false),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
|
||||
return [
|
||||
'allocation' => $rules['allocation_id'],
|
||||
'oom_disabled' => $rules['oom_disabled'],
|
||||
'exclude_from_resource_calculation' => $rules['exclude_from_resource_calculation'],
|
||||
|
||||
'limits' => 'sometimes|array',
|
||||
'limits.memory' => $this->requiredToOptional('memory', $rules['memory'], true),
|
||||
'limits.overhead_memory' => $this->requiredToOptional('overhead_memory', $rules['overhead_memory'], true),
|
||||
'limits.swap' => $this->requiredToOptional('swap', $rules['swap'], true),
|
||||
'limits.io' => $this->requiredToOptional('io', $rules['io'], true),
|
||||
'limits.cpu' => $this->requiredToOptional('cpu', $rules['cpu'], true),
|
||||
@@ -31,6 +33,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
|
||||
//
|
||||
// @see https://github.com/pterodactyl/panel/issues/1500
|
||||
'memory' => $this->requiredToOptional('memory', $rules['memory']),
|
||||
'overhead_memory' => $this->requiredToOptional('overhead_memory', $rules['overhead_memory']),
|
||||
'swap' => $this->requiredToOptional('swap', $rules['swap']),
|
||||
'io' => $this->requiredToOptional('io', $rules['io']),
|
||||
'cpu' => $this->requiredToOptional('cpu', $rules['cpu']),
|
||||
|
||||
@@ -14,6 +14,6 @@ class RestoreBackupRequest extends ClientApiRequest
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return ['truncate' => 'required|boolean'];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
|
||||
|
||||
use Pterodactyl\Models\Egg;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
/**
|
||||
* Request validation for applying egg configuration changes.
|
||||
*
|
||||
* Validates egg selection, Docker images, startup commands, and environment
|
||||
* variables with comprehensive cross-validation.
|
||||
*/
|
||||
class ApplyEggChangeRequest extends ClientApiRequest
|
||||
{
|
||||
public function permission(): string
|
||||
{
|
||||
return 'startup.software';
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'egg_id' => 'required|integer|exists:eggs,id',
|
||||
'nest_id' => 'required|integer|exists:nests,id',
|
||||
'docker_image' => 'sometimes|string|max:255',
|
||||
'startup_command' => 'sometimes|string|max:2048',
|
||||
'environment' => 'sometimes|array|max:50',
|
||||
'environment.*' => 'nullable|string|max:1024',
|
||||
'should_backup' => 'sometimes|boolean',
|
||||
'should_wipe' => 'sometimes|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
if ($this->filled(['egg_id', 'nest_id'])) {
|
||||
$egg = Egg::where('id', $this->input('egg_id'))
|
||||
->where('nest_id', $this->input('nest_id'))
|
||||
->first();
|
||||
|
||||
if (!$egg) {
|
||||
$validator->errors()->add('egg_id', 'The selected egg does not belong to the specified nest.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->filled('docker_image')) {
|
||||
$dockerImages = array_values($egg->docker_images ?? []);
|
||||
if (!empty($dockerImages) && !in_array($this->input('docker_image'), $dockerImages)) {
|
||||
$validator->errors()->add('docker_image', 'The selected Docker image is not allowed for this egg.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->filled('environment')) {
|
||||
$eggVariables = $egg->variables()->pluck('env_variable')->toArray();
|
||||
foreach ($this->input('environment', []) as $key => $value) {
|
||||
if (!in_array($key, $eggVariables)) {
|
||||
$validator->errors()->add("environment.{$key}", 'This environment variable is not valid for the selected egg.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
|
||||
|
||||
use Pterodactyl\Models\Egg;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
/**
|
||||
* Request validation for previewing egg configuration changes.
|
||||
*
|
||||
* Validates egg and nest selection to ensure proper relationship
|
||||
* before showing preview information.
|
||||
*/
|
||||
class PreviewEggRequest extends ClientApiRequest
|
||||
{
|
||||
public function permission(): string
|
||||
{
|
||||
return 'startup.software';
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'egg_id' => 'required|integer|exists:eggs,id',
|
||||
'nest_id' => 'required|integer|exists:nests,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
if ($this->filled(['egg_id', 'nest_id'])) {
|
||||
$egg = Egg::where('id', $this->input('egg_id'))
|
||||
->where('nest_id', $this->input('nest_id'))
|
||||
->first();
|
||||
|
||||
if (!$egg) {
|
||||
$validator->errors()->add('egg_id', 'The selected egg does not belong to the specified nest.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
|
||||
|
||||
use Webmozart\Assert\Assert;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
class RevertDockerImageRequest extends ClientApiRequest implements ClientPermissionsRequest
|
||||
{
|
||||
public function permission(): string
|
||||
{
|
||||
return Permission::ACTION_STARTUP_DOCKER_IMAGE;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Server $server */
|
||||
$server = $this->route()->parameter('server');
|
||||
|
||||
Assert::isInstanceOf($server, Server::class);
|
||||
|
||||
return [
|
||||
'confirm' => 'required|boolean|accepted',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'confirm.required' => 'You must confirm that you understand this action cannot be undone without administrator assistance.',
|
||||
'confirm.accepted' => 'You must confirm that you understand this action cannot be undone without administrator assistance.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server has a custom docker image that can be reverted.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
if (!parent::authorize()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var Server $server */
|
||||
$server = $this->route()->parameter('server');
|
||||
|
||||
try {
|
||||
// Check if the current image is not in the egg's allowed images
|
||||
// This indicates it was set by an administrator as a custom image
|
||||
return $server->hasCustomDockerImage();
|
||||
} catch (\RuntimeException $e) {
|
||||
// If there's an issue with the egg configuration, deny access
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
|
||||
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
/**
|
||||
* Request validation for server operation queries.
|
||||
*
|
||||
* Validates operation ID format and ensures proper authorization
|
||||
* for accessing server operation information.
|
||||
*/
|
||||
class ServerOperationRequest extends ClientApiRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('settings.egg', $this->route()->parameter('server'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'operation_id' => 'required|string|uuid',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'operation_id.required' => 'An operation ID is required.',
|
||||
'operation_id.uuid' => 'The operation ID must be a valid UUID.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Startup;
|
||||
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
class UpdateStartupCommandRequest extends ClientApiRequest
|
||||
{
|
||||
public function permission(): string
|
||||
{
|
||||
return Permission::ACTION_STARTUP_COMMAND;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'startup' => 'required|string|min:1|max:10000',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'startup' => 'startup command',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subdomain;
|
||||
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
use Pterodactyl\Models\Domain;
|
||||
use Pterodactyl\Models\ServerSubdomain;
|
||||
|
||||
class CreateSubdomainRequest extends ClientApiRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'subdomain' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:63',
|
||||
'regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/',
|
||||
function ($attribute, $value, $fail) {
|
||||
if (preg_match('/[<>"\']/', $value)) {
|
||||
$fail('Subdomain contains invalid characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
$reserved = ['www', 'mail', 'ftp', 'api', 'admin', 'root', 'panel',
|
||||
'localhost', 'wildcard', 'ns1', 'ns2', 'dns', 'smtp', 'pop',
|
||||
'imap', 'webmail', 'cpanel', 'whm', 'autodiscover', 'autoconfig'];
|
||||
if (in_array(strtolower($value), $reserved)) {
|
||||
$fail('This subdomain is reserved and cannot be used.');
|
||||
return;
|
||||
}
|
||||
|
||||
$domainId = $this->input('domain_id');
|
||||
if ($domainId) {
|
||||
$exists = ServerSubdomain::where('domain_id', $domainId)
|
||||
->where('subdomain', strtolower($value))
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$fail('This subdomain is already taken.');
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
'domain_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
'min:1',
|
||||
'exists:domains,id',
|
||||
function ($attribute, $value, $fail) {
|
||||
$domain = Domain::where('id', $value)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
if (!$domain) {
|
||||
$fail('The selected domain is not available.');
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Sanitize and normalize subdomain
|
||||
if ($this->has('subdomain')) {
|
||||
$subdomain = $this->input('subdomain');
|
||||
// Remove any potential harmful characters and normalize
|
||||
$subdomain = preg_replace('/[^a-z0-9-]/', '', strtolower(trim($subdomain)));
|
||||
// Remove multiple consecutive hyphens
|
||||
$subdomain = preg_replace('/-+/', '-', $subdomain);
|
||||
// Remove leading/trailing hyphens
|
||||
$subdomain = trim($subdomain, '-');
|
||||
|
||||
$this->merge([
|
||||
'subdomain' => $subdomain,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
app/Http/Requests/Base/LocaleRequest.php
Normal file
16
app/Http/Requests/Base/LocaleRequest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Base;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LocaleRequest extends FormRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'locale' => ['required', 'string', 'regex:/^[a-z][a-z]$/'],
|
||||
'namespace' => ['required', 'string', 'regex:/^[a-z]{1,191}$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Resources/ServerOperationResource.php
Normal file
43
app/Http/Resources/ServerOperationResource.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Pterodactyl\Models\ServerOperation;
|
||||
|
||||
/**
|
||||
* Resource for transforming server operations for API responses.
|
||||
*
|
||||
* Provides comprehensive operation information including status, timing,
|
||||
* and metadata for frontend consumption.
|
||||
*/
|
||||
class ServerOperationResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the server operation into an array.
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
/** @var ServerOperation $operation */
|
||||
$operation = $this->resource;
|
||||
|
||||
return [
|
||||
'operation_id' => $operation->operation_id,
|
||||
'type' => $operation->type,
|
||||
'status' => $operation->status,
|
||||
'message' => $operation->message,
|
||||
'created_at' => $operation->created_at->toISOString(),
|
||||
'updated_at' => $operation->updated_at->toISOString(),
|
||||
'started_at' => $operation->started_at?->toISOString(),
|
||||
'parameters' => $operation->parameters,
|
||||
'meta' => [
|
||||
'is_active' => $operation->isActive(),
|
||||
'is_completed' => $operation->isCompleted(),
|
||||
'has_failed' => $operation->hasFailed(),
|
||||
'has_timed_out' => $operation->hasTimedOut(),
|
||||
'can_be_cancelled' => $operation->isActive() && !$operation->hasFailed(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,60 @@
|
||||
namespace Pterodactyl\Http\ViewComposers;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Pterodactyl\Services\Helpers\AssetHashService;
|
||||
use Pterodactyl\Services\Captcha\CaptchaManager;
|
||||
use Pterodactyl\Contracts\Repository\SettingsRepositoryInterface;
|
||||
|
||||
class AssetComposer
|
||||
{
|
||||
/**
|
||||
* Provide access to the asset service in the views.
|
||||
*/
|
||||
public function compose(View $view): void
|
||||
{
|
||||
$view->with('siteConfiguration', [
|
||||
'name' => config('app.name') ?? 'Pterodactyl',
|
||||
'locale' => config('app.locale') ?? 'en',
|
||||
'recaptcha' => [
|
||||
'enabled' => config('recaptcha.enabled', false),
|
||||
'siteKey' => config('recaptcha.website_key') ?? '',
|
||||
],
|
||||
]);
|
||||
protected CaptchaManager $captcha;
|
||||
protected SettingsRepositoryInterface $settings;
|
||||
|
||||
public function __construct(CaptchaManager $captcha, SettingsRepositoryInterface $settings)
|
||||
{
|
||||
$this->captcha = $captcha;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide access to the asset service in the views.
|
||||
*/
|
||||
public function compose(View $view): void
|
||||
{
|
||||
$view->with('siteConfiguration', [
|
||||
'name' => config('app.name') ?? 'Pyrodactyl',
|
||||
'locale' => config('app.locale') ?? 'en',
|
||||
'timezone' => config('app.timezone') ?? '',
|
||||
'captcha' => [
|
||||
'enabled' => $this->captcha->getDefaultDriver() !== 'none',
|
||||
'provider' => $this->captcha->getDefaultDriver(),
|
||||
'siteKey' => $this->getSiteKeyForCurrentProvider(),
|
||||
'scriptIncludes' => $this->captcha->getScriptIncludes(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site key for the currently active captcha provider.
|
||||
*/
|
||||
private function getSiteKeyForCurrentProvider(): string
|
||||
{
|
||||
$provider = $this->captcha->getDefaultDriver();
|
||||
|
||||
if ($provider === 'none') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$driver = $this->captcha->driver();
|
||||
if (method_exists($driver, 'getSiteKey')) {
|
||||
return $driver->getSiteKey();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail to avoid exposing errors to frontend
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +68,20 @@ class RunTaskJob extends Job implements ShouldQueue
|
||||
$commandRepository->setServer($server)->send($this->task->payload);
|
||||
break;
|
||||
case Task::ACTION_BACKUP:
|
||||
// Mark the task as running before initiating the backup to prevent duplicate runs
|
||||
$this->task->update(['is_processing' => true]);
|
||||
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
|
||||
$this->task->update(['is_processing' => false]);
|
||||
break;
|
||||
default:
|
||||
throw new \InvalidArgumentException('Invalid task action provided: ' . $this->task->action);
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
// Reset the processing flag if there was an error
|
||||
if ($this->task->action === Task::ACTION_BACKUP) {
|
||||
$this->task->update(['is_processing' => false]);
|
||||
}
|
||||
|
||||
// If this isn't a DaemonConnectionException on a task that allows for failures
|
||||
// throw the exception back up the chain so that the task is stopped.
|
||||
if (!($this->task->continue_on_failure && $exception instanceof DaemonConnectionException)) {
|
||||
|
||||
394
app/Jobs/Server/ApplyEggChangeJob.php
Normal file
394
app/Jobs/Server/ApplyEggChangeJob.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Jobs\Server;
|
||||
|
||||
use Exception;
|
||||
use Carbon\Carbon;
|
||||
use Pterodactyl\Jobs\Job;
|
||||
use Pterodactyl\Models\Egg;
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Pterodactyl\Facades\Activity;
|
||||
use Pterodactyl\Models\ServerOperation;
|
||||
use Pterodactyl\Services\Servers\ReinstallServerService;
|
||||
use Pterodactyl\Services\Backups\InitiateBackupService;
|
||||
use Pterodactyl\Services\Servers\StartupModificationService;
|
||||
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
|
||||
use Pterodactyl\Exceptions\Service\Backup\BackupFailedException;
|
||||
use Pterodactyl\Services\ServerOperations\ServerOperationService;
|
||||
use Pterodactyl\Services\Subdomain\SubdomainManagementService;
|
||||
|
||||
/**
|
||||
* Queue job to apply server egg configuration changes.
|
||||
*
|
||||
* Handles the complete egg change process including backup creation,
|
||||
* file wiping, server configuration updates, and reinstallation.
|
||||
*/
|
||||
class ApplyEggChangeJob extends Job implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use SerializesModels;
|
||||
|
||||
public $timeout;
|
||||
public $tries = 1;
|
||||
public $failOnTimeout = true;
|
||||
|
||||
public function __construct(
|
||||
public Server $server,
|
||||
public User $user,
|
||||
public int $eggId,
|
||||
public int $nestId,
|
||||
public ?string $dockerImage,
|
||||
public ?string $startupCommand,
|
||||
public array $environment,
|
||||
public bool $shouldBackup,
|
||||
public bool $shouldWipe,
|
||||
public string $operationId
|
||||
) {
|
||||
$this->queue = 'standard';
|
||||
$this->timeout = config('server_operations.timeouts.egg_change', 1800);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the egg change job.
|
||||
*/
|
||||
public function handle(
|
||||
InitiateBackupService $backupService,
|
||||
ReinstallServerService $reinstallServerService,
|
||||
StartupModificationService $startupModificationService,
|
||||
DaemonFileRepository $fileRepository,
|
||||
ServerOperationService $operationService,
|
||||
SubdomainManagementService $subdomainService
|
||||
): void {
|
||||
$operation = null;
|
||||
|
||||
try {
|
||||
$operation = ServerOperation::where('operation_id', $this->operationId)->firstOrFail();
|
||||
$operation->markAsStarted();
|
||||
|
||||
Activity::actor($this->user)->event('server:software.change-started')
|
||||
->property([
|
||||
'operation_id' => $this->operationId,
|
||||
'from_egg' => $this->server->egg_id,
|
||||
'to_egg' => $this->eggId,
|
||||
'should_backup' => $this->shouldBackup,
|
||||
'should_wipe' => $this->shouldWipe,
|
||||
])
|
||||
->log();
|
||||
|
||||
$egg = Egg::query()
|
||||
->with(['variables', 'nest'])
|
||||
->findOrFail($this->eggId);
|
||||
|
||||
$backup = null;
|
||||
|
||||
if ($this->shouldBackup) {
|
||||
$backup = $this->createBackup($backupService, $operation);
|
||||
}
|
||||
|
||||
if ($this->shouldWipe) {
|
||||
$this->wipeServerFiles($fileRepository, $operation, $backup);
|
||||
}
|
||||
|
||||
$this->applyServerChanges($egg, $startupModificationService, $reinstallServerService, $operation, $subdomainService);
|
||||
|
||||
$this->logSuccessfulChange();
|
||||
|
||||
$operation->markAsCompleted('Software configuration applied successfully. Server installation completed.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->handleJobFailure($e, $operation);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create backup before proceeding with changes.
|
||||
*/
|
||||
private function createBackup(InitiateBackupService $backupService, ServerOperation $operation): Backup
|
||||
{
|
||||
$operation->updateProgress('Creating backup before proceeding...');
|
||||
|
||||
// Get current and target egg names for better backup naming
|
||||
$currentEgg = $this->server->egg;
|
||||
$targetEgg = Egg::find($this->eggId);
|
||||
|
||||
// Create descriptive backup name
|
||||
$backupName = sprintf(
|
||||
'Pre-Change Backup: %s → %s (%s)',
|
||||
$currentEgg->name ?? 'Unknown',
|
||||
$targetEgg->name ?? 'Unknown',
|
||||
now()->format('M j, Y g:i A')
|
||||
);
|
||||
|
||||
// Limit backup name length to prevent database issues
|
||||
if (strlen($backupName) > 190) {
|
||||
$backupName = substr($backupName, 0, 187) . '...';
|
||||
}
|
||||
|
||||
$backup = $backupService
|
||||
->setIsLocked(false)
|
||||
->handle($this->server, $backupName);
|
||||
|
||||
Activity::actor($this->user)->event('server:backup.software-change')
|
||||
->property([
|
||||
'backup_name' => $backupName,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'operation_id' => $this->operationId,
|
||||
'from_egg' => $this->server->egg_id,
|
||||
'to_egg' => $this->eggId,
|
||||
])
|
||||
->log();
|
||||
|
||||
$operation->updateProgress('Waiting for backup to complete...');
|
||||
$this->waitForBackupCompletion($backup, $operation);
|
||||
|
||||
$backup->refresh();
|
||||
if (!$backup->is_successful) {
|
||||
throw new BackupFailedException('Backup failed. Aborting software change to prevent data loss.');
|
||||
}
|
||||
|
||||
return $backup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe server files if requested.
|
||||
*/
|
||||
private function wipeServerFiles(DaemonFileRepository $fileRepository, ServerOperation $operation, ?Backup $backup): void
|
||||
{
|
||||
$operation->updateProgress('Wiping server files...');
|
||||
|
||||
try {
|
||||
$contents = $fileRepository->setServer($this->server)->getDirectory('/');
|
||||
|
||||
if (!empty($contents)) {
|
||||
$filesToDelete = array_map(function($item) {
|
||||
return $item['name'];
|
||||
}, $contents);
|
||||
|
||||
if (count($filesToDelete) > 1000) {
|
||||
Log::warning('Large number of files to delete', [
|
||||
'server_id' => $this->server->id,
|
||||
'file_count' => count($filesToDelete),
|
||||
]);
|
||||
}
|
||||
|
||||
$fileRepository->setServer($this->server)->deleteFiles('/', $filesToDelete);
|
||||
|
||||
Activity::actor($this->user)->event('server:files.software-change-wipe')
|
||||
->property([
|
||||
'operation_id' => $this->operationId,
|
||||
'from_egg' => $this->server->egg_id,
|
||||
'to_egg' => $this->eggId,
|
||||
'files_deleted' => count($filesToDelete),
|
||||
'backup_verified' => $backup ? true : false,
|
||||
])
|
||||
->log();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to wipe files', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if (!$backup) {
|
||||
throw new \RuntimeException('File wipe failed and no backup was created. Aborting operation to prevent data loss.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply server configuration changes.
|
||||
*/
|
||||
private function applyServerChanges(
|
||||
Egg $egg,
|
||||
StartupModificationService $startupModificationService,
|
||||
ReinstallServerService $reinstallServerService,
|
||||
ServerOperation $operation,
|
||||
SubdomainManagementService $subdomainService
|
||||
): void {
|
||||
$operation->updateProgress('Applying software configuration...');
|
||||
|
||||
DB::transaction(function () use ($egg, $startupModificationService, $reinstallServerService, $operation, $subdomainService) {
|
||||
// Check if we need to remove subdomain before changing egg
|
||||
$activeSubdomain = $this->server->activeSubdomain;
|
||||
if ($activeSubdomain) {
|
||||
// Create a temporary server with the new egg to check compatibility
|
||||
$tempServer = clone $this->server;
|
||||
$tempServer->egg = $egg;
|
||||
$tempServer->egg_id = $egg->id;
|
||||
|
||||
// If new egg doesn't support subdomains, delete the existing subdomain
|
||||
if (!$tempServer->supportsSubdomains()) {
|
||||
$operation->updateProgress('Removing incompatible subdomain...');
|
||||
|
||||
try {
|
||||
$subdomainService->deleteSubdomain($activeSubdomain);
|
||||
|
||||
Activity::actor($this->user)->event('server:subdomain.deleted-egg-change')
|
||||
->property([
|
||||
'operation_id' => $this->operationId,
|
||||
'subdomain' => $activeSubdomain->full_domain,
|
||||
'reason' => 'new_egg_incompatible',
|
||||
'from_egg' => $this->server->egg_id,
|
||||
'to_egg' => $this->eggId,
|
||||
])
|
||||
->log();
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed to delete subdomain during egg change', [
|
||||
'server_id' => $this->server->id,
|
||||
'subdomain' => $activeSubdomain->full_domain,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Continue with egg change even if subdomain deletion fails
|
||||
$operation->updateProgress('Warning: Could not fully remove subdomain, continuing with egg change...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->server->egg_id !== $this->eggId || $this->server->nest_id !== $this->nestId) {
|
||||
$this->server->update([
|
||||
'egg_id' => $this->eggId,
|
||||
'nest_id' => $this->nestId,
|
||||
]);
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'startup' => $this->startupCommand ?: $egg->startup,
|
||||
'docker_image' => $this->dockerImage,
|
||||
'environment' => $this->environment,
|
||||
];
|
||||
|
||||
$updatedServer = $startupModificationService
|
||||
->setUserLevel(User::USER_LEVEL_ADMIN)
|
||||
->handle($this->server, $updateData);
|
||||
|
||||
$operation->updateProgress('Reinstalling server...');
|
||||
$reinstallServerService->handle($updatedServer);
|
||||
|
||||
$operation->updateProgress('Finalizing installation...');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log successful software change.
|
||||
*/
|
||||
private function logSuccessfulChange(): void
|
||||
{
|
||||
Activity::actor($this->user)->event('server:software.changed')
|
||||
->property([
|
||||
'operation_id' => $this->operationId,
|
||||
'original_egg_id' => $this->server->getOriginal('egg_id'),
|
||||
'new_egg_id' => $this->eggId,
|
||||
'original_nest_id' => $this->server->getOriginal('nest_id'),
|
||||
'new_nest_id' => $this->nestId,
|
||||
'original_image' => $this->server->getOriginal('image'),
|
||||
'new_image' => $this->dockerImage,
|
||||
'backup_created' => $this->shouldBackup,
|
||||
'files_wiped' => $this->shouldWipe,
|
||||
])
|
||||
->log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle job failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
try {
|
||||
$operation = ServerOperation::where('operation_id', $this->operationId)->first();
|
||||
|
||||
Log::error('Egg change job failed', [
|
||||
'server_id' => $this->server->id,
|
||||
'operation_id' => $this->operationId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
if ($operation) {
|
||||
$operation->markAsFailed('Job failed: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
Activity::actor($this->user)->event('server:software.change-job-failed')
|
||||
->property([
|
||||
'operation_id' => $this->operationId,
|
||||
'error' => $exception->getMessage(),
|
||||
'attempted_egg_id' => $this->eggId,
|
||||
])
|
||||
->log();
|
||||
} catch (\Throwable $e) {
|
||||
Log::critical('Failed to handle job failure properly', [
|
||||
'operation_id' => $this->operationId,
|
||||
'original_error' => $exception->getMessage(),
|
||||
'handler_error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for backup completion with timeout monitoring.
|
||||
*/
|
||||
private function waitForBackupCompletion(Backup $backup, ServerOperation $operation, int $timeoutMinutes = 30): void
|
||||
{
|
||||
$startTime = Carbon::now();
|
||||
$timeout = $startTime->addMinutes($timeoutMinutes);
|
||||
$lastProgressUpdate = 0;
|
||||
|
||||
while (Carbon::now()->lt($timeout)) {
|
||||
$backup->refresh();
|
||||
|
||||
if ($backup->is_successful && !is_null($backup->completed_at)) {
|
||||
$operation->updateProgress('Backup completed successfully');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_null($backup->completed_at) && !$backup->is_successful) {
|
||||
throw new BackupFailedException('Backup failed during creation process.');
|
||||
}
|
||||
|
||||
$elapsed = Carbon::now()->diffInSeconds($startTime);
|
||||
if ($elapsed - $lastProgressUpdate >= 30) {
|
||||
$operation->updateProgress("Backup in progress...");
|
||||
$lastProgressUpdate = $elapsed;
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
throw new BackupFailedException('Backup creation timed out after ' . $timeoutMinutes . ' minutes.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle job failure with error logging.
|
||||
*/
|
||||
private function handleJobFailure(\Throwable $exception, ?ServerOperation $operation): void
|
||||
{
|
||||
Log::error('Egg change job failed', [
|
||||
'operation_id' => $this->operationId,
|
||||
'error' => $exception->getMessage(),
|
||||
'server_id' => $this->server->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
if ($operation) {
|
||||
$operation->markAsFailed('Operation failed: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
Activity::actor($this->user)->event('server:software.change-failed')
|
||||
->property([
|
||||
'operation_id' => $this->operationId,
|
||||
'error' => $exception->getMessage(),
|
||||
'attempted_egg_id' => $this->eggId,
|
||||
'attempted_nest_id' => $this->nestId,
|
||||
])
|
||||
->log();
|
||||
}
|
||||
}
|
||||
@@ -62,153 +62,153 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
*/
|
||||
class ApiKey extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\ApiKeyFactory> */
|
||||
use HasFactory;
|
||||
/** @use HasFactory<\Database\Factories\ApiKeyFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* The resource name for this model when it is transformed into an
|
||||
* API representation using fractal.
|
||||
*/
|
||||
public const RESOURCE_NAME = 'api_key';
|
||||
/**
|
||||
* Different API keys that can exist on the system.
|
||||
*/
|
||||
public const TYPE_NONE = 0;
|
||||
public const TYPE_ACCOUNT = 1;
|
||||
/* @deprecated */
|
||||
public const TYPE_APPLICATION = 2;
|
||||
/* @deprecated */
|
||||
public const TYPE_DAEMON_USER = 3;
|
||||
/* @deprecated */
|
||||
public const TYPE_DAEMON_APPLICATION = 4;
|
||||
/**
|
||||
* The length of API key identifiers.
|
||||
*/
|
||||
public const IDENTIFIER_LENGTH = 16;
|
||||
/**
|
||||
* The length of the actual API key that is encrypted and stored
|
||||
* in the database.
|
||||
*/
|
||||
public const KEY_LENGTH = 32;
|
||||
/**
|
||||
* The resource name for this model when it is transformed into an
|
||||
* API representation using fractal.
|
||||
*/
|
||||
public const RESOURCE_NAME = 'api_key';
|
||||
/**
|
||||
* Different API keys that can exist on the system.
|
||||
*/
|
||||
public const TYPE_NONE = 0;
|
||||
public const TYPE_ACCOUNT = 1;
|
||||
/* @deprecated */
|
||||
public const TYPE_APPLICATION = 2;
|
||||
/* @deprecated */
|
||||
public const TYPE_DAEMON_USER = 3;
|
||||
/* @deprecated */
|
||||
public const TYPE_DAEMON_APPLICATION = 4;
|
||||
/**
|
||||
* The length of API key identifiers.
|
||||
*/
|
||||
public const IDENTIFIER_LENGTH = 16;
|
||||
/**
|
||||
* The length of the actual API key that is encrypted and stored
|
||||
* in the database.
|
||||
*/
|
||||
public const KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*/
|
||||
protected $table = 'api_keys';
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*/
|
||||
protected $table = 'api_keys';
|
||||
|
||||
/**
|
||||
* Cast values to correct type.
|
||||
*/
|
||||
protected $casts = [
|
||||
'allowed_ips' => 'array',
|
||||
'user_id' => 'int',
|
||||
'last_used_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
self::CREATED_AT => 'datetime',
|
||||
self::UPDATED_AT => 'datetime',
|
||||
'r_' . AdminAcl::RESOURCE_USERS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_DATABASE_HOSTS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_SERVER_DATABASES => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_EGGS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_LOCATIONS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_NESTS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_NODES => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_SERVERS => 'int',
|
||||
];
|
||||
/**
|
||||
* Cast values to correct type.
|
||||
*/
|
||||
protected $casts = [
|
||||
'allowed_ips' => 'array',
|
||||
'user_id' => 'int',
|
||||
'last_used_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
self::CREATED_AT => 'datetime',
|
||||
self::UPDATED_AT => 'datetime',
|
||||
'r_' . AdminAcl::RESOURCE_USERS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_DATABASE_HOSTS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_SERVER_DATABASES => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_EGGS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_LOCATIONS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_NESTS => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_NODES => 'int',
|
||||
'r_' . AdminAcl::RESOURCE_SERVERS => 'int',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'identifier',
|
||||
'token',
|
||||
'allowed_ips',
|
||||
'memo',
|
||||
'last_used_at',
|
||||
'expires_at',
|
||||
];
|
||||
/**
|
||||
* Fields that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'identifier',
|
||||
'token',
|
||||
'allowed_ips',
|
||||
'memo',
|
||||
'last_used_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields that should not be included when calling toArray() or toJson()
|
||||
* on this model.
|
||||
*/
|
||||
protected $hidden = ['token'];
|
||||
/**
|
||||
* Fields that should not be included when calling toArray() or toJson()
|
||||
* on this model.
|
||||
*/
|
||||
protected $hidden = ['token'];
|
||||
|
||||
/**
|
||||
* Rules to protect against invalid data entry to DB.
|
||||
*/
|
||||
public static array $validationRules = [
|
||||
'user_id' => 'required|exists:users,id',
|
||||
'key_type' => 'present|integer|min:0|max:4',
|
||||
'identifier' => 'required|string|size:16|unique:api_keys,identifier',
|
||||
'token' => 'required|string',
|
||||
'memo' => 'required|nullable|string|max:500',
|
||||
'allowed_ips' => 'nullable|array',
|
||||
'allowed_ips.*' => 'string',
|
||||
'last_used_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date',
|
||||
'r_' . AdminAcl::RESOURCE_USERS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_DATABASE_HOSTS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_SERVER_DATABASES => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_EGGS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_LOCATIONS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_NESTS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_NODES => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_SERVERS => 'integer|min:0|max:3',
|
||||
];
|
||||
/**
|
||||
* Rules to protect against invalid data entry to DB.
|
||||
*/
|
||||
public static array $validationRules = [
|
||||
'user_id' => 'required|exists:users,id',
|
||||
'key_type' => 'present|integer|min:0|max:4',
|
||||
'identifier' => 'required|string|size:16|unique:api_keys,identifier',
|
||||
'token' => 'required|string',
|
||||
'memo' => 'required|nullable|string|max:500',
|
||||
'allowed_ips' => 'nullable|array',
|
||||
'allowed_ips.*' => 'string',
|
||||
'last_used_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date',
|
||||
'r_' . AdminAcl::RESOURCE_USERS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_DATABASE_HOSTS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_SERVER_DATABASES => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_EGGS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_LOCATIONS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_NESTS => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_NODES => 'integer|min:0|max:3',
|
||||
'r_' . AdminAcl::RESOURCE_SERVERS => 'integer|min:0|max:3',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the user this token is assigned to.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
/**
|
||||
* Returns the user this token is assigned to.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Required for support with Laravel Sanctum.
|
||||
*
|
||||
* @see \Laravel\Sanctum\Guard::supportsTokens()
|
||||
*/
|
||||
public function tokenable(): BelongsTo
|
||||
{
|
||||
return $this->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the model matching the provided token.
|
||||
*/
|
||||
public static function findToken(string $token): ?self
|
||||
{
|
||||
$identifier = substr($token, 0, self::IDENTIFIER_LENGTH);
|
||||
|
||||
$model = static::where('identifier', $identifier)->first();
|
||||
if (!is_null($model) && decrypt($model->token) === substr($token, strlen($identifier))) {
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required for support with Laravel Sanctum.
|
||||
*
|
||||
* @see \Laravel\Sanctum\Guard::supportsTokens()
|
||||
*/
|
||||
public function tokenable(): BelongsTo
|
||||
{
|
||||
return $this->user();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the model matching the provided token.
|
||||
*/
|
||||
public static function findToken(string $token): ?self
|
||||
{
|
||||
$identifier = substr($token, 0, self::IDENTIFIER_LENGTH);
|
||||
/**
|
||||
* Returns the standard prefix for API keys in the system.
|
||||
*/
|
||||
public static function getPrefixForType(int $type): string
|
||||
{
|
||||
Assert::oneOf($type, [self::TYPE_ACCOUNT, self::TYPE_APPLICATION]);
|
||||
|
||||
$model = static::where('identifier', $identifier)->first();
|
||||
if (!is_null($model) && decrypt($model->token) === substr($token, strlen($identifier))) {
|
||||
return $model;
|
||||
}
|
||||
return $type === self::TYPE_ACCOUNT ? 'pyro_' : 'pyro_';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Generates a new identifier for an API key.
|
||||
*/
|
||||
public static function generateTokenIdentifier(int $type): string
|
||||
{
|
||||
$prefix = self::getPrefixForType($type);
|
||||
|
||||
/**
|
||||
* Returns the standard prefix for API keys in the system.
|
||||
*/
|
||||
public static function getPrefixForType(int $type): string
|
||||
{
|
||||
Assert::oneOf($type, [self::TYPE_ACCOUNT, self::TYPE_APPLICATION]);
|
||||
|
||||
return $type === self::TYPE_ACCOUNT ? 'ptlc_' : 'ptla_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new identifier for an API key.
|
||||
*/
|
||||
public static function generateTokenIdentifier(int $type): string
|
||||
{
|
||||
$prefix = self::getPrefixForType($type);
|
||||
|
||||
return $prefix . Str::random(self::IDENTIFIER_LENGTH - strlen($prefix));
|
||||
}
|
||||
return $prefix . Str::random(self::IDENTIFIER_LENGTH - strlen($prefix));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
* @property bool $is_locked
|
||||
* @property string $name
|
||||
* @property string[] $ignored_files
|
||||
* @property array|null $server_state
|
||||
* @property string $disk
|
||||
* @property string|null $checksum
|
||||
* @property int $bytes
|
||||
@@ -45,6 +46,7 @@ class Backup extends Model
|
||||
'is_successful' => 'bool',
|
||||
'is_locked' => 'bool',
|
||||
'ignored_files' => 'array',
|
||||
'server_state' => 'array',
|
||||
'bytes' => 'int',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
@@ -66,6 +68,7 @@ class Backup extends Model
|
||||
'is_locked' => 'boolean',
|
||||
'name' => 'required|string',
|
||||
'ignored_files' => 'array',
|
||||
'server_state' => 'nullable|array',
|
||||
'disk' => 'required|string',
|
||||
'checksum' => 'nullable|string',
|
||||
'bytes' => 'numeric',
|
||||
|
||||
98
app/Models/Domain.php
Normal file
98
app/Models/Domain.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $dns_provider
|
||||
* @property array $dns_config
|
||||
* @property bool $is_active
|
||||
* @property bool $is_default
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\ServerSubdomain[] $serverSubdomains
|
||||
*/
|
||||
class Domain extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DomainFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* The resource name for this model when it is transformed into an
|
||||
* API representation using fractal.
|
||||
*/
|
||||
public const RESOURCE_NAME = 'domain';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*/
|
||||
protected $table = 'domains';
|
||||
|
||||
/**
|
||||
* Fields that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'dns_provider',
|
||||
'dns_config',
|
||||
'is_active',
|
||||
'is_default',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cast values to correct type.
|
||||
*/
|
||||
protected $casts = [
|
||||
'dns_config' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
self::CREATED_AT => 'datetime',
|
||||
self::UPDATED_AT => 'datetime',
|
||||
];
|
||||
|
||||
public static array $validationRules = [
|
||||
'name' => 'required|string|max:191|unique:domains',
|
||||
'dns_provider' => 'required|string|max:191',
|
||||
'dns_config' => 'required|array',
|
||||
'is_active' => 'sometimes|boolean',
|
||||
'is_default' => 'sometimes|boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets all server subdomains associated with this domain.
|
||||
*/
|
||||
public function serverSubdomains(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSubdomain::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets only active server subdomains associated with this domain.
|
||||
*/
|
||||
public function activeSubdomains(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSubdomain::class)->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route key for the model.
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default domain for automatic subdomain generation.
|
||||
*/
|
||||
public static function getDefault(): ?self
|
||||
{
|
||||
return static::where('is_active', true)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
* @property string|null $description
|
||||
* @property int $location_id
|
||||
* @property string $fqdn
|
||||
* @property string|null $internal_fqdn
|
||||
* @property bool $use_separate_fqdns
|
||||
* @property string $scheme
|
||||
* @property bool $behind_proxy
|
||||
* @property bool $maintenance_mode
|
||||
@@ -77,18 +79,34 @@ class Node extends Model
|
||||
'behind_proxy' => 'boolean',
|
||||
'public' => 'boolean',
|
||||
'maintenance_mode' => 'boolean',
|
||||
'use_separate_fqdns' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'public', 'name', 'location_id',
|
||||
'fqdn', 'scheme', 'behind_proxy',
|
||||
'memory', 'memory_overallocate', 'disk',
|
||||
'disk_overallocate', 'upload_size', 'daemonBase',
|
||||
'daemonSFTP', 'daemonListen',
|
||||
'description', 'maintenance_mode',
|
||||
'uuid',
|
||||
'public',
|
||||
'name',
|
||||
'location_id',
|
||||
'fqdn',
|
||||
'internal_fqdn',
|
||||
'use_separate_fqdns',
|
||||
'scheme',
|
||||
'behind_proxy',
|
||||
'memory',
|
||||
'memory_overallocate',
|
||||
'disk',
|
||||
'disk_overallocate',
|
||||
'upload_size',
|
||||
'daemonBase',
|
||||
'daemonSFTP',
|
||||
'daemonListen',
|
||||
'daemon_token_id',
|
||||
'daemon_token',
|
||||
'description',
|
||||
'maintenance_mode',
|
||||
];
|
||||
|
||||
public static array $validationRules = [
|
||||
@@ -97,6 +115,8 @@ class Node extends Model
|
||||
'location_id' => 'required|exists:locations,id',
|
||||
'public' => 'boolean',
|
||||
'fqdn' => 'required|string',
|
||||
'internal_fqdn' => 'nullable|string',
|
||||
'use_separate_fqdns' => 'sometimes|boolean',
|
||||
'scheme' => 'required',
|
||||
'behind_proxy' => 'boolean',
|
||||
'memory' => 'required|numeric|min:1',
|
||||
@@ -122,16 +142,42 @@ class Node extends Model
|
||||
'daemonSFTP' => 2022,
|
||||
'daemonListen' => 8080,
|
||||
'maintenance_mode' => false,
|
||||
'use_separate_fqdns' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the connection address to use when making calls to this node.
|
||||
* This will use the internal FQDN if separate FQDNs are enabled and internal_fqdn is set,
|
||||
* otherwise it will fall back to the regular fqdn.
|
||||
*/
|
||||
public function getConnectionAddress(): string
|
||||
{
|
||||
$fqdn = $this->getInternalFqdn();
|
||||
return sprintf('%s://%s:%s', $this->scheme, $fqdn, $this->daemonListen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the browser connection address for WebSocket connections.
|
||||
* This always uses the public fqdn field.
|
||||
*/
|
||||
public function getBrowserConnectionAddress(): string
|
||||
{
|
||||
return sprintf('%s://%s:%s', $this->scheme, $this->fqdn, $this->daemonListen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate FQDN for internal panel-to-Wings communication.
|
||||
*/
|
||||
public function getInternalFqdn(): string
|
||||
{
|
||||
// Use internal FQDN if it's provided and not empty
|
||||
if (!empty($this->internal_fqdn)) {
|
||||
return $this->internal_fqdn;
|
||||
}
|
||||
|
||||
return $this->fqdn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration as an array.
|
||||
*/
|
||||
@@ -147,8 +193,8 @@ class Node extends Model
|
||||
'port' => $this->daemonListen,
|
||||
'ssl' => [
|
||||
'enabled' => (!$this->behind_proxy && $this->scheme === 'https'),
|
||||
'cert' => '/etc/letsencrypt/live/' . Str::lower($this->fqdn) . '/fullchain.pem',
|
||||
'key' => '/etc/letsencrypt/live/' . Str::lower($this->fqdn) . '/privkey.pem',
|
||||
'cert' => '/etc/letsencrypt/live/' . Str::lower($this->getInternalFqdn()) . '/fullchain.pem',
|
||||
'key' => '/etc/letsencrypt/live/' . Str::lower($this->getInternalFqdn()) . '/privkey.pem',
|
||||
],
|
||||
'upload_limit' => $this->upload_size,
|
||||
],
|
||||
@@ -231,6 +277,10 @@ class Node extends Model
|
||||
$memoryLimit = $this->memory * (1 + ($this->memory_overallocate / 100));
|
||||
$diskLimit = $this->disk * (1 + ($this->disk_overallocate / 100));
|
||||
|
||||
return ($this->sum_memory + $memory) <= $memoryLimit && ($this->sum_disk + $disk) <= $diskLimit;
|
||||
// Calculate used resources excluding servers marked for exclusion
|
||||
$usedMemory = $this->servers()->where('exclude_from_resource_calculation', false)->sum('memory');
|
||||
$usedDisk = $this->servers()->where('exclude_from_resource_calculation', false)->sum('disk');
|
||||
|
||||
return ($usedMemory + $memory) <= $memoryLimit && ($usedDisk + $disk) <= $diskLimit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ class Permission extends Model
|
||||
|
||||
public const ACTION_STARTUP_READ = 'startup.read';
|
||||
public const ACTION_STARTUP_UPDATE = 'startup.update';
|
||||
public const ACTION_STARTUP_COMMAND = 'startup.command';
|
||||
public const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image';
|
||||
|
||||
public const ACTION_STARTUP_SOFTWARE = 'startup.software';
|
||||
@@ -172,6 +173,7 @@ class Permission extends Model
|
||||
'keys' => [
|
||||
'read' => 'Allows a user to view the startup variables for a server.',
|
||||
'update' => 'Allows a user to modify the startup variables for the server.',
|
||||
'command' => 'Allows a user to modify the startup command for the server.',
|
||||
'docker-image' => 'Allows a user to modify the Docker image used when running the server.',
|
||||
'software' => 'Allows a user to modify the game / software used for the server.',
|
||||
],
|
||||
|
||||
@@ -120,8 +120,8 @@ class Schedule extends Model
|
||||
{
|
||||
$formatted = sprintf('%s %s %s %s %s', $this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_month, $this->cron_day_of_week);
|
||||
|
||||
return CarbonImmutable::createFromTimestamp(
|
||||
(new CronExpression($formatted))->getNextRunDate()->getTimestamp()
|
||||
return CarbonImmutable::instance(
|
||||
(new CronExpression($formatted))->getNextRunDate()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,14 @@ use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
|
||||
* @property bool $skip_scripts
|
||||
* @property int $owner_id
|
||||
* @property int $memory
|
||||
* @property int $overhead_memory
|
||||
* @property int $swap
|
||||
* @property int $disk
|
||||
* @property int $io
|
||||
* @property int $cpu
|
||||
* @property string|null $threads
|
||||
* @property bool $oom_disabled
|
||||
* @property bool $exclude_from_resource_calculation
|
||||
* @property int $allocation_id
|
||||
* @property int $nest_id
|
||||
* @property int $egg_id
|
||||
@@ -87,6 +89,7 @@ use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Server whereImage($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Server whereIo($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Server whereMemory($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Server whereOverheadMemory($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Server whereName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Server whereNestId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Server whereNodeId($value)
|
||||
@@ -134,6 +137,7 @@ class Server extends Model
|
||||
protected $attributes = [
|
||||
'status' => self::STATUS_INSTALLING,
|
||||
'oom_disabled' => true,
|
||||
'exclude_from_resource_calculation' => false,
|
||||
'installed_at' => null,
|
||||
];
|
||||
|
||||
@@ -155,11 +159,13 @@ class Server extends Model
|
||||
'description' => 'string',
|
||||
'status' => 'nullable|string',
|
||||
'memory' => 'required|numeric|min:0',
|
||||
'overhead_memory' => 'sometimes|numeric|min:0',
|
||||
'swap' => 'required|numeric|min:-1',
|
||||
'io' => 'required|numeric|between:10,1000',
|
||||
'cpu' => 'required|numeric|min:0',
|
||||
'threads' => 'nullable|regex:/^[0-9-,]+$/',
|
||||
'oom_disabled' => 'sometimes|boolean',
|
||||
'exclude_from_resource_calculation' => 'sometimes|boolean',
|
||||
'disk' => 'required|numeric|min:0',
|
||||
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
|
||||
'nest_id' => 'required|exists:nests,id',
|
||||
@@ -180,11 +186,13 @@ class Server extends Model
|
||||
'skip_scripts' => 'boolean',
|
||||
'owner_id' => 'integer',
|
||||
'memory' => 'integer',
|
||||
'overhead_memory' => 'integer',
|
||||
'swap' => 'integer',
|
||||
'disk' => 'integer',
|
||||
'io' => 'integer',
|
||||
'cpu' => 'integer',
|
||||
'oom_disabled' => 'boolean',
|
||||
'exclude_from_resource_calculation' => 'boolean',
|
||||
'allocation_id' => 'integer',
|
||||
'nest_id' => 'integer',
|
||||
'egg_id' => 'integer',
|
||||
@@ -217,6 +225,40 @@ class Server extends Model
|
||||
return $this->status === self::STATUS_SUSPENDED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server has a custom docker image set by an administrator.
|
||||
* A custom image is one that is not in the egg's allowed docker images.
|
||||
*/
|
||||
public function hasCustomDockerImage(): bool
|
||||
{
|
||||
// Ensure we have egg data and docker images
|
||||
if (!$this->egg || !is_array($this->egg->docker_images) || empty($this->egg->docker_images)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !in_array($this->image, array_values($this->egg->docker_images));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default docker image from the egg specification.
|
||||
*/
|
||||
public function getDefaultDockerImage(): string
|
||||
{
|
||||
// Ensure we have egg data and docker images
|
||||
if (!$this->egg || !is_array($this->egg->docker_images) || empty($this->egg->docker_images)) {
|
||||
throw new \RuntimeException('Server egg has no docker images configured.');
|
||||
}
|
||||
|
||||
$eggDockerImages = $this->egg->docker_images;
|
||||
$defaultImage = reset($eggDockerImages);
|
||||
|
||||
if (empty($defaultImage)) {
|
||||
throw new \RuntimeException('Server egg has no valid default docker image.');
|
||||
}
|
||||
|
||||
return $defaultImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user who owns the server.
|
||||
*/
|
||||
@@ -346,6 +388,82 @@ class Server extends Model
|
||||
return $this->morphToMany(ActivityLog::class, 'subject', 'activity_log_subjects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all subdomains associated with this server.
|
||||
*/
|
||||
public function subdomains(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSubdomain::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the active subdomain for this server.
|
||||
*/
|
||||
public function activeSubdomain(): HasOne
|
||||
{
|
||||
return $this->hasOne(ServerSubdomain::class)->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this server supports subdomains based on its egg features.
|
||||
*/
|
||||
public function supportsSubdomains(): bool
|
||||
{
|
||||
if (!$this->egg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check direct features
|
||||
if (is_array($this->egg->features)) {
|
||||
foreach ($this->egg->features as $feature) {
|
||||
if (str_starts_with($feature, 'subdomain_')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check inherited features
|
||||
if (is_array($this->egg->inherit_features)) {
|
||||
foreach ($this->egg->inherit_features as $feature) {
|
||||
if (str_starts_with($feature, 'subdomain_')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subdomain feature type for this server.
|
||||
*/
|
||||
public function getSubdomainFeature(): ?string
|
||||
{
|
||||
if (!$this->egg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check direct features
|
||||
if (is_array($this->egg->features)) {
|
||||
foreach ($this->egg->features as $feature) {
|
||||
if (str_starts_with($feature, 'subdomain_')) {
|
||||
return $feature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check inherited features
|
||||
if (is_array($this->egg->inherit_features)) {
|
||||
foreach ($this->egg->inherit_features as $feature) {
|
||||
if (str_starts_with($feature, 'subdomain_')) {
|
||||
return $feature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server is currently in a user-accessible state. If not, an
|
||||
* exception is raised. This should be called whenever something needs to make
|
||||
|
||||
165
app/Models/ServerOperation.php
Normal file
165
app/Models/ServerOperation.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Server operations tracking model.
|
||||
*
|
||||
* Tracks long-running server operations like egg changes, reinstalls, and backup restores.
|
||||
* Provides status tracking, timeout detection, and operation lifecycle management.
|
||||
*/
|
||||
class ServerOperation extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_RUNNING = 'running';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const TYPE_EGG_CHANGE = 'egg_change';
|
||||
public const TYPE_REINSTALL = 'reinstall';
|
||||
public const TYPE_BACKUP_RESTORE = 'backup_restore';
|
||||
|
||||
protected $table = 'server_operations';
|
||||
|
||||
protected $fillable = [
|
||||
'operation_id',
|
||||
'server_id',
|
||||
'user_id',
|
||||
'type',
|
||||
'status',
|
||||
'message',
|
||||
'parameters',
|
||||
'started_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parameters' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_PENDING, self::STATUS_RUNNING]);
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function hasFailed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED;
|
||||
}
|
||||
|
||||
public function scopeForServer($query, Server $server)
|
||||
{
|
||||
return $query->where('server_id', $server->id);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_PENDING, self::STATUS_RUNNING]);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
public function scopeTimedOut($query, int $timeoutMinutes = 30)
|
||||
{
|
||||
return $query->where('status', self::STATUS_RUNNING)
|
||||
->whereNotNull('started_at')
|
||||
->where('started_at', '<', now()->subMinutes($timeoutMinutes));
|
||||
}
|
||||
|
||||
public function scopeForCleanup($query, int $daysOld = 30)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED])
|
||||
->where('created_at', '<', now()->subDays($daysOld));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation has exceeded the timeout threshold.
|
||||
*/
|
||||
public function hasTimedOut(int $timeoutMinutes = 30): bool
|
||||
{
|
||||
if (!$this->isActive() || !$this->started_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->started_at->diffInMinutes(now()) > $timeoutMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark operation as started and update status.
|
||||
*/
|
||||
public function markAsStarted(): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_RUNNING,
|
||||
'started_at' => now(),
|
||||
'message' => 'Operation started...',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark operation as completed with optional message.
|
||||
*/
|
||||
public function markAsCompleted(string $message = 'Operation completed successfully'): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark operation as failed with error message.
|
||||
*/
|
||||
public function markAsFailed(string $message): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update operation progress message.
|
||||
*/
|
||||
public function updateProgress(string $message): bool
|
||||
{
|
||||
return $this->update(['message' => $message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operation duration in seconds if started.
|
||||
*/
|
||||
public function getDurationInSeconds(): ?int
|
||||
{
|
||||
if (!$this->started_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->started_at->diffInSeconds(now());
|
||||
}
|
||||
}
|
||||
101
app/Models/ServerSubdomain.php
Normal file
101
app/Models/ServerSubdomain.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $server_id
|
||||
* @property int $domain_id
|
||||
* @property string $subdomain
|
||||
* @property string $record_type
|
||||
* @property array $dns_records
|
||||
* @property bool $is_active
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property Server $server
|
||||
* @property Domain $domain
|
||||
*/
|
||||
class ServerSubdomain extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\ServerSubdomainFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* The resource name for this model when it is transformed into an
|
||||
* API representation using fractal.
|
||||
*/
|
||||
public const RESOURCE_NAME = 'server_subdomain';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*/
|
||||
protected $table = 'server_subdomains';
|
||||
|
||||
/**
|
||||
* Fields that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'server_id',
|
||||
'domain_id',
|
||||
'subdomain',
|
||||
'record_type',
|
||||
'dns_records',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cast values to correct type.
|
||||
*/
|
||||
protected $casts = [
|
||||
'server_id' => 'integer',
|
||||
'domain_id' => 'integer',
|
||||
'dns_records' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
self::CREATED_AT => 'datetime',
|
||||
self::UPDATED_AT => 'datetime',
|
||||
];
|
||||
|
||||
public static array $validationRules = [
|
||||
'server_id' => 'required|integer|exists:servers,id',
|
||||
'domain_id' => 'required|integer|exists:domains,id',
|
||||
'subdomain' => 'required|string|max:191',
|
||||
'record_type' => 'required|string|max:10',
|
||||
'dns_records' => 'sometimes|array',
|
||||
'is_active' => 'sometimes|boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the server this subdomain belongs to.
|
||||
*/
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the domain this subdomain uses.
|
||||
*/
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full domain name (subdomain + domain).
|
||||
*/
|
||||
public function getFullDomainAttribute(): string
|
||||
{
|
||||
return $this->subdomain . '.' . $this->domain->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this subdomain has DNS records configured.
|
||||
*/
|
||||
public function hasDnsRecords(): bool
|
||||
{
|
||||
return !empty($this->dns_records);
|
||||
}
|
||||
}
|
||||
53
app/Models/SessionActivity.php
Normal file
53
app/Models/SessionActivity.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $ip_address
|
||||
* @property string $user_agent
|
||||
* @property array $payload
|
||||
* @property int $last_activity
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property User $user
|
||||
*/
|
||||
class SessionActivity extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*/
|
||||
protected $table = 'sessions';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'payload',
|
||||
'last_activity',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*/
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'payload' => 'array',
|
||||
'last_activity' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns this session activity.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
64
app/Observers/AllocationObserver.php
Normal file
64
app/Observers/AllocationObserver.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Observers;
|
||||
|
||||
use Pterodactyl\Models\Allocation;
|
||||
use Pterodactyl\Services\Subdomain\SubdomainManagementService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AllocationObserver
|
||||
{
|
||||
public function __construct(private SubdomainManagementService $subdomainService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Allocation "updated" event.
|
||||
*/
|
||||
public function updated(Allocation $allocation): void
|
||||
{
|
||||
// Check if IP or port changed
|
||||
if ($allocation->isDirty(['ip', 'port'])) {
|
||||
$this->syncSubdomainForAllocation($allocation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Allocation "deleting" event.
|
||||
*/
|
||||
public function deleting(Allocation $allocation): void
|
||||
{
|
||||
// If this is a primary allocation being deleted, sync subdomains
|
||||
if ($allocation->server && $allocation->server->allocation_id === $allocation->id) {
|
||||
$this->syncSubdomainForAllocation($allocation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync subdomain DNS records for servers using this allocation.
|
||||
*/
|
||||
private function syncSubdomainForAllocation(Allocation $allocation): void
|
||||
{
|
||||
if (!$allocation->server) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only sync if this is the primary allocation for the server
|
||||
if ($allocation->server->allocation_id !== $allocation->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activeSubdomain = $allocation->server->activeSubdomain;
|
||||
|
||||
if (!$activeSubdomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->subdomainService->updateSubdomain($activeSubdomain);
|
||||
Log::info("Updated DNS records for subdomain {$activeSubdomain->full_domain} due to allocation change");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to update DNS records for subdomain {$activeSubdomain->full_domain}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/Observers/EggObserver.php
Normal file
56
app/Observers/EggObserver.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Observers;
|
||||
|
||||
use Pterodactyl\Models\Egg;
|
||||
|
||||
class EggObserver
|
||||
{
|
||||
/**
|
||||
* Handle the Egg "creating" event.
|
||||
*/
|
||||
public function creating(Egg $egg): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Egg "created" event.
|
||||
*/
|
||||
public function created(Egg $egg): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Egg "updating" event.
|
||||
*/
|
||||
public function updating(Egg $egg): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Egg "updated" event.
|
||||
*/
|
||||
public function updated(Egg $egg): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Egg "deleting" event.
|
||||
*/
|
||||
public function deleting(Egg $egg): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Egg "deleted" event.
|
||||
*/
|
||||
public function deleted(Egg $egg): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -2,75 +2,161 @@
|
||||
|
||||
namespace Pterodactyl\Observers;
|
||||
|
||||
use Pterodactyl\Events;
|
||||
use Pterodactyl\Models\Domain;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Pterodactyl\Models\ServerSubdomain;
|
||||
use Pterodactyl\Services\Subdomain\SubdomainManagementService;
|
||||
use Pterodactyl\Services\Subdomain\SubdomainGeneratorService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerObserver
|
||||
{
|
||||
use DispatchesJobs;
|
||||
|
||||
/**
|
||||
* Listen to the Server creating event.
|
||||
*/
|
||||
public function creating(Server $server): void
|
||||
{
|
||||
event(new Events\Server\Creating($server));
|
||||
public function __construct(
|
||||
private SubdomainManagementService $subdomainService,
|
||||
private SubdomainGeneratorService $subdomainGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the Server created event.
|
||||
* Handle the Server "created" event.
|
||||
*/
|
||||
public function created(Server $server): void
|
||||
{
|
||||
event(new Events\Server\Created($server));
|
||||
// Check if server supports subdomains
|
||||
$feature = $this->subdomainService->getServerSubdomainFeature($server);
|
||||
if (!$feature) {
|
||||
Log::info("Server {$server->id} does not support subdomains. Features: " . json_encode($server->egg->features));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get default domain
|
||||
$domain = Domain::getDefault();
|
||||
if (!$domain) {
|
||||
Log::warning("No default domain available for subdomain creation. Please set a default domain in the admin panel.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$domain->is_active) {
|
||||
Log::warning("Default domain {$domain->name} is not active");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing subdomains for uniqueness check
|
||||
$existingSubdomains = ServerSubdomain::where('domain_id', $domain->id)
|
||||
->where('is_active', true)
|
||||
->pluck('subdomain')
|
||||
->toArray();
|
||||
|
||||
// Generate unique subdomain
|
||||
$subdomain = $this->subdomainGenerator->generateUnique($existingSubdomains);
|
||||
|
||||
try {
|
||||
$this->subdomainService->createSubdomain($server, $domain, $subdomain);
|
||||
Log::info("Created subdomain {$subdomain}.{$domain->name} for new server {$server->id}");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to create subdomain for server {$server->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the Server deleting event.
|
||||
*/
|
||||
public function deleting(Server $server): void
|
||||
{
|
||||
event(new Events\Server\Deleting($server));
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the Server deleted event.
|
||||
*/
|
||||
public function deleted(Server $server): void
|
||||
{
|
||||
event(new Events\Server\Deleted($server));
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the Server saving event.
|
||||
*/
|
||||
public function saving(Server $server): void
|
||||
{
|
||||
event(new Events\Server\Saving($server));
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the Server saved event.
|
||||
*/
|
||||
public function saved(Server $server): void
|
||||
{
|
||||
event(new Events\Server\Saved($server));
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the Server updating event.
|
||||
*/
|
||||
public function updating(Server $server): void
|
||||
{
|
||||
event(new Events\Server\Updating($server));
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the Server saved event.
|
||||
* Handle the Server "updated" event.
|
||||
*/
|
||||
public function updated(Server $server): void
|
||||
{
|
||||
event(new Events\Server\Updated($server));
|
||||
// Check if allocation_id changed (primary allocation changed)
|
||||
if ($server->isDirty('allocation_id')) {
|
||||
$this->syncSubdomainRecords($server);
|
||||
}
|
||||
|
||||
// Check if egg_id changed (server software changed)
|
||||
if ($server->isDirty('egg_id')) {
|
||||
$this->handleServerSoftwareChange($server);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Server "deleting" event.
|
||||
*/
|
||||
public function deleting(Server $server): void
|
||||
{
|
||||
// Delete all subdomains when server is deleted
|
||||
$subdomains = $server->subdomains()->where('is_active', true)->get();
|
||||
|
||||
foreach ($subdomains as $subdomain) {
|
||||
try {
|
||||
$this->subdomainService->deleteSubdomain($subdomain);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Failed to delete subdomain {$subdomain->full_domain} for server {$server->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync subdomain DNS records when server allocation changes.
|
||||
*/
|
||||
private function syncSubdomainRecords(Server $server): void
|
||||
{
|
||||
$activeSubdomain = $server->activeSubdomain;
|
||||
|
||||
if (!$activeSubdomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->subdomainService->updateSubdomain($activeSubdomain);
|
||||
Log::info("Updated DNS records for subdomain {$activeSubdomain->full_domain} due to server allocation change");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to update DNS records for subdomain {$activeSubdomain->full_domain}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle server software changes.
|
||||
*/
|
||||
private function handleServerSoftwareChange(Server $server): void
|
||||
{
|
||||
$activeSubdomain = $server->activeSubdomain;
|
||||
|
||||
if (!$activeSubdomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the new egg supports subdomains
|
||||
$feature = $this->subdomainService->getServerSubdomainFeature($server);
|
||||
|
||||
if (!$feature) {
|
||||
// New software doesn't support subdomains, delete the subdomain
|
||||
try {
|
||||
$this->subdomainService->deleteSubdomain($activeSubdomain);
|
||||
Log::info("Deleted subdomain {$activeSubdomain->full_domain} because new server software doesn't support subdomains");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to delete subdomain {$activeSubdomain->full_domain} after software change: {$e->getMessage()}");
|
||||
}
|
||||
} else {
|
||||
// New software supports subdomains, check if record type changed
|
||||
$newRecordType = str_replace('subdomain_', '', $feature->getFeatureName());
|
||||
if ($newRecordType !== $activeSubdomain->record_type) {
|
||||
try {
|
||||
// Delete old records and create new ones
|
||||
$this->subdomainService->deleteSubdomain($activeSubdomain);
|
||||
$this->subdomainService->createSubdomain(
|
||||
$server,
|
||||
$activeSubdomain->domain,
|
||||
$activeSubdomain->subdomain
|
||||
);
|
||||
Log::info("Recreated subdomain {$activeSubdomain->full_domain} with new record type due to software change");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to recreate subdomain {$activeSubdomain->full_domain} after software change: {$e->getMessage()}");
|
||||
}
|
||||
} else {
|
||||
// Same record type, just update records
|
||||
try {
|
||||
$this->subdomainService->updateSubdomain($activeSubdomain);
|
||||
Log::info("Updated DNS records for subdomain {$activeSubdomain->full_domain} due to server software change");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to update DNS records for subdomain {$activeSubdomain->full_domain}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
app/Observers/SessionActivityObserver.php
Normal file
56
app/Observers/SessionActivityObserver.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Observers;
|
||||
|
||||
use Pterodactyl\Models\SessionActivity;
|
||||
|
||||
class SessionActivityObserver
|
||||
{
|
||||
/**
|
||||
* Handle the SessionActivity "creating" event.
|
||||
*/
|
||||
public function creating(SessionActivity $sessionActivity): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SessionActivity "created" event.
|
||||
*/
|
||||
public function created(SessionActivity $sessionActivity): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SessionActivity "updating" event.
|
||||
*/
|
||||
public function updating(SessionActivity $sessionActivity): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SessionActivity "updated" event.
|
||||
*/
|
||||
public function updated(SessionActivity $sessionActivity): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SessionActivity "deleting" event.
|
||||
*/
|
||||
public function deleting(SessionActivity $sessionActivity): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SessionActivity "deleted" event.
|
||||
*/
|
||||
public function deleted(SessionActivity $sessionActivity): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->singleton('extensions.themes', function () {
|
||||
return new Theme();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,18 +2,31 @@
|
||||
|
||||
namespace Pterodactyl\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Pterodactyl\Services\Captcha\CaptchaManager;
|
||||
|
||||
class BladeServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Perform post-registration booting of services.
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->app->make('blade.compiler')
|
||||
->directive('datetimeHuman', function ($expression) {
|
||||
return "<?php echo \Carbon\CarbonImmutable::createFromFormat(\Carbon\CarbonImmutable::DEFAULT_TO_STRING_FORMAT, $expression)->setTimezone(config('app.timezone'))->toDateTimeString(); ?>";
|
||||
});
|
||||
Blade::directive('captcha', function ($form) {
|
||||
return "<?php echo app('" . CaptchaManager::class . "')->getWidget($form); ?>";
|
||||
});
|
||||
|
||||
Blade::if('captchaEnabled', function () {
|
||||
return app(CaptchaManager::class)->getDefaultDriver() !== 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Providers/CaptchaServiceProvider.php
Normal file
30
app/Providers/CaptchaServiceProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Pterodactyl\Services\Captcha\CaptchaManager;
|
||||
use Pterodactyl\Contracts\Repository\SettingsRepositoryInterface;
|
||||
|
||||
class CaptchaServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(CaptchaManager::class, function ($app) {
|
||||
return new CaptchaManager($app, $app->make(SettingsRepositoryInterface::class));
|
||||
});
|
||||
|
||||
$this->app->alias(CaptchaManager::class, 'captcha');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,9 @@
|
||||
namespace Pterodactyl\Providers;
|
||||
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Subuser;
|
||||
use Pterodactyl\Models\EggVariable;
|
||||
use Pterodactyl\Observers\UserObserver;
|
||||
use Pterodactyl\Observers\ServerObserver;
|
||||
use Pterodactyl\Observers\SubuserObserver;
|
||||
use Pterodactyl\Observers\EggVariableObserver;
|
||||
use Pterodactyl\Listeners\Auth\AuthenticationListener;
|
||||
@@ -36,7 +34,6 @@ class EventServiceProvider extends ServiceProvider
|
||||
parent::boot();
|
||||
|
||||
User::observe(UserObserver::class);
|
||||
Server::observe(ServerObserver::class);
|
||||
Subuser::observe(SubuserObserver::class);
|
||||
EggVariable::observe(EggVariableObserver::class);
|
||||
}
|
||||
|
||||
44
app/Providers/ObserverServiceProvider.php
Normal file
44
app/Providers/ObserverServiceProvider.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Providers;
|
||||
|
||||
use Pterodactyl\Models\Egg;
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Subuser;
|
||||
use Pterodactyl\Models\Allocation;
|
||||
use Pterodactyl\Models\EggVariable;
|
||||
use Pterodactyl\Models\SessionActivity;
|
||||
use Pterodactyl\Observers\EggObserver;
|
||||
use Pterodactyl\Observers\UserObserver;
|
||||
use Pterodactyl\Observers\ServerObserver;
|
||||
use Pterodactyl\Observers\SubuserObserver;
|
||||
use Pterodactyl\Observers\AllocationObserver;
|
||||
use Pterodactyl\Observers\EggVariableObserver;
|
||||
use Pterodactyl\Observers\SessionActivityObserver;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ObserverServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
User::observe(UserObserver::class);
|
||||
Server::observe(ServerObserver::class);
|
||||
Subuser::observe(SubuserObserver::class);
|
||||
Allocation::observe(AllocationObserver::class);
|
||||
Egg::observe(EggObserver::class);
|
||||
EggVariable::observe(EggVariableObserver::class);
|
||||
SessionActivity::observe(SessionActivityObserver::class);
|
||||
}
|
||||
}
|
||||
36
app/Providers/ServerOperationServiceProvider.php
Normal file
36
app/Providers/ServerOperationServiceProvider.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Pterodactyl\Http\Middleware\Api\Client\Server\ServerOperationRateLimit;
|
||||
|
||||
/**
|
||||
* Service provider for server operations functionality.
|
||||
*
|
||||
* Registers commands, middleware, scheduled tasks, and configuration
|
||||
* for the server operations system.
|
||||
*/
|
||||
class ServerOperationServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// No commands to register currently
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$router = $this->app['router'];
|
||||
$router->aliasMiddleware('server.operation.rate-limit', ServerOperationRateLimit::class);
|
||||
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../config/server_operations.php' => config_path('server_operations.php'),
|
||||
], 'server-operations-config');
|
||||
}
|
||||
}
|
||||
@@ -12,102 +12,111 @@ use Pterodactyl\Contracts\Repository\SettingsRepositoryInterface;
|
||||
|
||||
class SettingsServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* An array of configuration keys to override with database values
|
||||
* if they exist.
|
||||
*/
|
||||
protected array $keys = [
|
||||
'app:name',
|
||||
'app:locale',
|
||||
'recaptcha:enabled',
|
||||
'recaptcha:secret_key',
|
||||
'recaptcha:website_key',
|
||||
'pterodactyl:guzzle:timeout',
|
||||
'pterodactyl:guzzle:connect_timeout',
|
||||
'pterodactyl:console:count',
|
||||
'pterodactyl:console:frequency',
|
||||
'pterodactyl:auth:2fa_required',
|
||||
'pterodactyl:client_features:allocations:enabled',
|
||||
'pterodactyl:client_features:allocations:range_start',
|
||||
'pterodactyl:client_features:allocations:range_end',
|
||||
];
|
||||
/**
|
||||
* An array of configuration keys to override with database values
|
||||
* if they exist.
|
||||
*/
|
||||
protected array $keys = [
|
||||
'app:name',
|
||||
'app:locale',
|
||||
// existing mail keys, etc...
|
||||
'pterodactyl:guzzle:timeout',
|
||||
'pterodactyl:guzzle:connect_timeout',
|
||||
'pterodactyl:console:count',
|
||||
'pterodactyl:console:frequency',
|
||||
'pterodactyl:auth:2fa_required',
|
||||
'pterodactyl:client_features:allocations:enabled',
|
||||
'pterodactyl:client_features:allocations:range_start',
|
||||
'pterodactyl:client_features:allocations:range_end',
|
||||
'pterodactyl:captcha:provider',
|
||||
'pterodactyl:captcha:turnstile:site_key',
|
||||
'pterodactyl:captcha:turnstile:secret_key',
|
||||
'pterodactyl:captcha:hcaptcha:site_key',
|
||||
'pterodactyl:captcha:hcaptcha:secret_key',
|
||||
'pterodactyl:captcha:recaptcha:site_key',
|
||||
'pterodactyl:captcha:recaptcha:secret_key',
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys specific to the mail driver that are only grabbed from the database
|
||||
* when using the SMTP driver.
|
||||
*/
|
||||
protected array $emailKeys = [
|
||||
'mail:mailers:smtp:host',
|
||||
'mail:mailers:smtp:port',
|
||||
'mail:mailers:smtp:encryption',
|
||||
'mail:mailers:smtp:username',
|
||||
'mail:mailers:smtp:password',
|
||||
'mail:from:address',
|
||||
'mail:from:name',
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that are encrypted and should be decrypted when set in the
|
||||
* configuration array.
|
||||
*/
|
||||
protected static array $encrypted = [
|
||||
'mail:mailers:smtp:password',
|
||||
];
|
||||
/**
|
||||
* Keys specific to the mail driver that are only grabbed from the database
|
||||
* when using the SMTP driver.
|
||||
*/
|
||||
protected array $emailKeys = [
|
||||
'mail:mailers:smtp:host',
|
||||
'mail:mailers:smtp:port',
|
||||
'mail:mailers:smtp:encryption',
|
||||
'mail:mailers:smtp:username',
|
||||
'mail:mailers:smtp:password',
|
||||
'mail:from:address',
|
||||
'mail:from:name',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boot the service provider.
|
||||
*/
|
||||
public function boot(ConfigRepository $config, Encrypter $encrypter, Log $log, SettingsRepositoryInterface $settings): void
|
||||
{
|
||||
// Only set the email driver settings from the database if we
|
||||
// are configured using SMTP as the driver.
|
||||
if ($config->get('mail.default') === 'smtp') {
|
||||
$this->keys = array_merge($this->keys, $this->emailKeys);
|
||||
}
|
||||
/**
|
||||
* Keys that are encrypted and should be decrypted when set in the
|
||||
* configuration array.
|
||||
*/
|
||||
protected static array $encrypted = [
|
||||
'mail:mailers:smtp:password',
|
||||
'pterodactyl:captcha:turnstile:secret_key',
|
||||
'pterodactyl:captcha:hcaptcha:secret_key',
|
||||
'pterodactyl:captcha:recaptcha:secret_key',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boot the service provider.
|
||||
*/
|
||||
public function boot(ConfigRepository $config, Encrypter $encrypter, Log $log, SettingsRepositoryInterface $settings): void
|
||||
{
|
||||
// Only set the email driver settings from the database if we
|
||||
// are configured using SMTP as the driver.
|
||||
if ($config->get('mail.default') === 'smtp') {
|
||||
$this->keys = array_merge($this->keys, $this->emailKeys);
|
||||
}
|
||||
|
||||
try {
|
||||
$values = $settings->all()->mapWithKeys(function ($setting) {
|
||||
return [$setting->key => $setting->value];
|
||||
})->toArray();
|
||||
} catch (QueryException $exception) {
|
||||
$log->notice('A query exception was encountered while trying to load settings from the database: ' . $exception->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->keys as $key) {
|
||||
$value = array_get($values, 'settings::' . $key, $config->get(str_replace(':', '.', $key)));
|
||||
if (in_array($key, self::$encrypted)) {
|
||||
try {
|
||||
$values = $settings->all()->mapWithKeys(function ($setting) {
|
||||
return [$setting->key => $setting->value];
|
||||
})->toArray();
|
||||
} catch (QueryException $exception) {
|
||||
$log->notice('A query exception was encountered while trying to load settings from the database: ' . $exception->getMessage());
|
||||
|
||||
return;
|
||||
$value = $encrypter->decrypt($value);
|
||||
} catch (DecryptException $exception) {
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->keys as $key) {
|
||||
$value = array_get($values, 'settings::' . $key, $config->get(str_replace(':', '.', $key)));
|
||||
if (in_array($key, self::$encrypted)) {
|
||||
try {
|
||||
$value = $encrypter->decrypt($value);
|
||||
} catch (DecryptException $exception) {
|
||||
}
|
||||
}
|
||||
switch (strtolower($value)) {
|
||||
case 'true':
|
||||
case '(true)':
|
||||
$value = true;
|
||||
break;
|
||||
case 'false':
|
||||
case '(false)':
|
||||
$value = false;
|
||||
break;
|
||||
case 'empty':
|
||||
case '(empty)':
|
||||
$value = '';
|
||||
break;
|
||||
case 'null':
|
||||
case '(null)':
|
||||
$value = null;
|
||||
}
|
||||
|
||||
switch (strtolower($value)) {
|
||||
case 'true':
|
||||
case '(true)':
|
||||
$value = true;
|
||||
break;
|
||||
case 'false':
|
||||
case '(false)':
|
||||
$value = false;
|
||||
break;
|
||||
case 'empty':
|
||||
case '(empty)':
|
||||
$value = '';
|
||||
break;
|
||||
case 'null':
|
||||
case '(null)':
|
||||
$value = null;
|
||||
}
|
||||
|
||||
$config->set(str_replace(':', '.', $key), $value);
|
||||
}
|
||||
$config->set(str_replace(':', '.', $key), $value);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getEncryptedKeys(): array
|
||||
{
|
||||
return self::$encrypted;
|
||||
}
|
||||
public static function getEncryptedKeys(): array
|
||||
{
|
||||
return self::$encrypted;
|
||||
}
|
||||
}
|
||||
|
||||
27
app/Providers/SubdomainServiceProvider.php
Normal file
27
app/Providers/SubdomainServiceProvider.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Pterodactyl\Services\Subdomain\SubdomainManagementService;
|
||||
|
||||
class SubdomainServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(SubdomainManagementService::class, function ($app) {
|
||||
return new SubdomainManagementService();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,16 @@ class BackupRepository extends EloquentRepository
|
||||
->orWhere('is_successful', true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns backups that are currently in progress for a specific server.
|
||||
*/
|
||||
public function getBackupsInProgress(int $serverId): Collection
|
||||
{
|
||||
return $this->getBuilder()
|
||||
->where('server_id', $serverId)
|
||||
->whereNull('completed_at')
|
||||
->get()
|
||||
->toBase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,45 @@ class LocationRepository extends EloquentRepository implements LocationRepositor
|
||||
*/
|
||||
public function getAllWithDetails(): Collection
|
||||
{
|
||||
return $this->getBuilder()->withCount('nodes', 'servers')->get($this->getColumns());
|
||||
$locations = $this->getBuilder()->withCount('nodes', 'servers')->get($this->getColumns());
|
||||
|
||||
foreach ($locations as $location) {
|
||||
$nodes = $location->nodes()->with('servers')->get();
|
||||
|
||||
$totalMemory = 0;
|
||||
$allocatedMemory = 0;
|
||||
$totalDisk = 0;
|
||||
$allocatedDisk = 0;
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$baseMemoryLimit = $node->memory;
|
||||
$baseDiskLimit = $node->disk;
|
||||
$memoryLimit = $baseMemoryLimit * (1 + ($node->memory_overallocate / 100));
|
||||
$diskLimit = $baseDiskLimit * (1 + ($node->disk_overallocate / 100));
|
||||
|
||||
$totalMemory += $memoryLimit;
|
||||
$totalDisk += $diskLimit;
|
||||
|
||||
$nodeAllocatedMemory = $node->servers->where('exclude_from_resource_calculation', false)->sum('memory');
|
||||
$nodeAllocatedDisk = $node->servers->where('exclude_from_resource_calculation', false)->sum('disk');
|
||||
|
||||
$allocatedMemory += $nodeAllocatedMemory;
|
||||
$allocatedDisk += $nodeAllocatedDisk;
|
||||
}
|
||||
|
||||
$totalBaseMemory = $nodes->sum('memory');
|
||||
$totalBaseDisk = $nodes->sum('disk');
|
||||
|
||||
$location->memory_percent = $totalBaseMemory > 0 ? ($allocatedMemory / $totalBaseMemory) * 100 : 0;
|
||||
$location->disk_percent = $totalBaseDisk > 0 ? ($allocatedDisk / $totalBaseDisk) * 100 : 0;
|
||||
|
||||
$location->total_memory = $totalMemory;
|
||||
$location->allocated_memory = $allocatedMemory;
|
||||
$location->total_disk = $totalDisk;
|
||||
$location->allocated_disk = $allocatedDisk;
|
||||
}
|
||||
|
||||
return $locations;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,16 +25,18 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa
|
||||
->selectRaw('COALESCE(SUM(servers.memory), 0) as sum_memory, COALESCE(SUM(servers.disk), 0) as sum_disk')
|
||||
->join('servers', 'servers.node_id', '=', 'nodes.id')
|
||||
->where('node_id', '=', $node->id)
|
||||
->where('servers.exclude_from_resource_calculation', '=', false)
|
||||
->first();
|
||||
|
||||
return Collection::make(['disk' => $stats->sum_disk, 'memory' => $stats->sum_memory])
|
||||
->mapWithKeys(function ($value, $key) use ($node) {
|
||||
$maxUsage = $node->{$key};
|
||||
$baseLimit = $node->{$key};
|
||||
$maxUsage = $baseLimit;
|
||||
if ($node->{$key . '_overallocate'} > 0) {
|
||||
$maxUsage = $node->{$key} * (1 + ($node->{$key . '_overallocate'} / 100));
|
||||
$maxUsage = $baseLimit * (1 + ($node->{$key . '_overallocate'} / 100));
|
||||
}
|
||||
|
||||
$percent = ($value / $maxUsage) * 100;
|
||||
$percent = ($value / $baseLimit) * 100;
|
||||
|
||||
return [
|
||||
$key => [
|
||||
@@ -55,18 +57,23 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa
|
||||
{
|
||||
$stats = $this->getBuilder()->select(
|
||||
$this->getBuilder()->raw('COALESCE(SUM(servers.memory), 0) as sum_memory, COALESCE(SUM(servers.disk), 0) as sum_disk')
|
||||
)->join('servers', 'servers.node_id', '=', 'nodes.id')->where('node_id', $node->id)->first();
|
||||
)->join('servers', 'servers.node_id', '=', 'nodes.id')
|
||||
->where('node_id', $node->id)
|
||||
->where('servers.exclude_from_resource_calculation', '=', false)
|
||||
->first();
|
||||
|
||||
return collect(['disk' => $stats->sum_disk, 'memory' => $stats->sum_memory])->mapWithKeys(function ($value, $key) use ($node) {
|
||||
$maxUsage = $node->{$key};
|
||||
$baseLimit = $node->{$key};
|
||||
$maxUsage = $baseLimit;
|
||||
if ($node->{$key . '_overallocate'} > 0) {
|
||||
$maxUsage = $node->{$key} * (1 + ($node->{$key . '_overallocate'} / 100));
|
||||
$maxUsage = $baseLimit * (1 + ($node->{$key . '_overallocate'} / 100));
|
||||
}
|
||||
|
||||
return [
|
||||
$key => [
|
||||
'value' => $value,
|
||||
'max' => $maxUsage,
|
||||
'base_limit' => $baseLimit,
|
||||
],
|
||||
];
|
||||
})->toArray();
|
||||
@@ -144,7 +151,10 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa
|
||||
$instance = $this->getBuilder()
|
||||
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemonListen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
|
||||
->selectRaw('COALESCE(SUM(servers.memory), 0) as sum_memory, COALESCE(SUM(servers.disk), 0) as sum_disk')
|
||||
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
|
||||
->leftJoin('servers', function($join) {
|
||||
$join->on('servers.node_id', '=', 'nodes.id')
|
||||
->where('servers.exclude_from_resource_calculation', '=', false);
|
||||
})
|
||||
->where('nodes.id', $node_id);
|
||||
|
||||
return $instance->first();
|
||||
|
||||
@@ -54,10 +54,11 @@ class DaemonBackupRepository extends DaemonRepository
|
||||
|
||||
/**
|
||||
* Sends a request to Wings to begin restoring a backup for a server.
|
||||
* Always truncates the directory for a clean restore.
|
||||
*
|
||||
* @throws DaemonConnectionException
|
||||
*/
|
||||
public function restore(Backup $backup, ?string $url = null, bool $truncate = false): ResponseInterface
|
||||
public function restore(Backup $backup, ?string $url = null): ResponseInterface
|
||||
{
|
||||
Assert::isInstanceOf($this->server, Server::class);
|
||||
|
||||
@@ -67,7 +68,7 @@ class DaemonBackupRepository extends DaemonRepository
|
||||
[
|
||||
'json' => [
|
||||
'adapter' => $backup->disk,
|
||||
'truncate_directory' => $truncate,
|
||||
'truncate_directory' => true,
|
||||
'download_url' => $url ?? '',
|
||||
],
|
||||
]
|
||||
|
||||
@@ -18,7 +18,7 @@ class AssignmentService
|
||||
public const CIDR_MIN_BITS = 32;
|
||||
public const PORT_FLOOR = 1024;
|
||||
public const PORT_CEIL = 65535;
|
||||
public const PORT_RANGE_LIMIT = 1000;
|
||||
public const PORT_RANGE_LIMIT = 100000;
|
||||
public const PORT_RANGE_REGEX = '/^(\d{4,5})-(\d{4,5})$/';
|
||||
|
||||
/**
|
||||
@@ -52,6 +52,12 @@ class AssignmentService
|
||||
// an array of records, which is not ideal for this use case, we need a SINGLE
|
||||
// IP to use, not multiple.
|
||||
$underlying = gethostbyname($data['allocation_ip']);
|
||||
|
||||
// Validate that gethostbyname returned a valid IP
|
||||
if (!filter_var($underlying, FILTER_VALIDATE_IP)) {
|
||||
throw new DisplayException("gethostbyname returned invalid IP address: {$underlying} for input: {$data['allocation_ip']}");
|
||||
}
|
||||
|
||||
$parsed = Network::parse($underlying);
|
||||
} catch (\Exception $exception) {
|
||||
/* @noinspection PhpUndefinedVariableInspection */
|
||||
@@ -78,9 +84,16 @@ class AssignmentService
|
||||
}
|
||||
|
||||
foreach ($block as $unit) {
|
||||
$ipString = $ip->__toString();
|
||||
|
||||
// Validate the IP string before insertion
|
||||
if (!filter_var($ipString, FILTER_VALIDATE_IP)) {
|
||||
throw new DisplayException("Invalid IP address generated: {$ipString}");
|
||||
}
|
||||
|
||||
$insertData[] = [
|
||||
'node_id' => $node->id,
|
||||
'ip' => $ip->__toString(),
|
||||
'ip' => $ipString,
|
||||
'port' => (int) $unit,
|
||||
'ip_alias' => array_get($data, 'allocation_alias'),
|
||||
'server_id' => null,
|
||||
@@ -91,9 +104,16 @@ class AssignmentService
|
||||
throw new PortOutOfRangeException();
|
||||
}
|
||||
|
||||
$ipString = $ip->__toString();
|
||||
|
||||
// Validate the IP string before insertion
|
||||
if (!filter_var($ipString, FILTER_VALIDATE_IP)) {
|
||||
throw new DisplayException("Invalid IP address generated: {$ipString}");
|
||||
}
|
||||
|
||||
$insertData[] = [
|
||||
'node_id' => $node->id,
|
||||
'ip' => $ip->__toString(),
|
||||
'ip' => $ipString,
|
||||
'port' => (int) $port,
|
||||
'ip_alias' => array_get($data, 'allocation_alias'),
|
||||
'server_id' => null,
|
||||
|
||||
@@ -34,19 +34,41 @@ class FindAssignableAllocationService
|
||||
throw new AutoAllocationNotEnabledException();
|
||||
}
|
||||
|
||||
// Validate that the server has a valid primary allocation IP
|
||||
if (!$server->allocation) {
|
||||
throw new \Pterodactyl\Exceptions\DisplayException("Server has no primary allocation");
|
||||
}
|
||||
|
||||
$allocationIp = $server->allocation->ip;
|
||||
|
||||
// If it's not a valid IP, try to resolve it as a hostname
|
||||
if (!filter_var($allocationIp, FILTER_VALIDATE_IP)) {
|
||||
$resolvedIp = gethostbyname($allocationIp);
|
||||
|
||||
// If gethostbyname fails, it returns the original hostname
|
||||
if ($resolvedIp === $allocationIp || !filter_var($resolvedIp, FILTER_VALIDATE_IP)) {
|
||||
throw new \Pterodactyl\Exceptions\DisplayException(
|
||||
"Cannot resolve allocation IP/hostname '{$allocationIp}' to a valid IP address"
|
||||
);
|
||||
}
|
||||
|
||||
// Use the resolved IP for allocation operations
|
||||
$allocationIp = $resolvedIp;
|
||||
}
|
||||
|
||||
// Attempt to find a given available allocation for a server. If one cannot be found
|
||||
// we will fall back to attempting to create a new allocation that can be used for the
|
||||
// server.
|
||||
/** @var Allocation|null $allocation */
|
||||
$allocation = $server->node->allocations()
|
||||
->where('ip', $server->allocation->ip)
|
||||
->where('ip', $allocationIp)
|
||||
->whereNull('server_id')
|
||||
->inRandomOrder()
|
||||
->first();
|
||||
|
||||
$allocation = $allocation ?? $this->createNewAllocation($server);
|
||||
$allocation = $allocation ?? $this->createNewAllocation($server, $allocationIp);
|
||||
|
||||
$allocation->update(['server_id' => $server->id]);
|
||||
$allocation->skipValidation()->update(['server_id' => $server->id]);
|
||||
|
||||
return $allocation->refresh();
|
||||
}
|
||||
@@ -62,7 +84,7 @@ class FindAssignableAllocationService
|
||||
* @throws \Pterodactyl\Exceptions\Service\Allocation\PortOutOfRangeException
|
||||
* @throws \Pterodactyl\Exceptions\Service\Allocation\TooManyPortsInRangeException
|
||||
*/
|
||||
protected function createNewAllocation(Server $server): Allocation
|
||||
protected function createNewAllocation(Server $server, string $resolvedIp): Allocation
|
||||
{
|
||||
$start = config('pterodactyl.client_features.allocations.range_start', null);
|
||||
$end = config('pterodactyl.client_features.allocations.range_end', null);
|
||||
@@ -77,7 +99,7 @@ class FindAssignableAllocationService
|
||||
// Get all of the currently allocated ports for the node so that we can figure out
|
||||
// which port might be available.
|
||||
$ports = $server->node->allocations()
|
||||
->where('ip', $server->allocation->ip)
|
||||
->where('ip', $resolvedIp)
|
||||
->whereBetween('port', [$start, $end])
|
||||
->pluck('port');
|
||||
|
||||
@@ -96,13 +118,13 @@ class FindAssignableAllocationService
|
||||
$port = $available[array_rand($available)];
|
||||
|
||||
$this->service->handle($server->node, [
|
||||
'allocation_ip' => $server->allocation->ip,
|
||||
'allocation_ip' => $resolvedIp,
|
||||
'allocation_ports' => [$port],
|
||||
]);
|
||||
|
||||
/** @var Allocation $allocation */
|
||||
$allocation = $server->node->allocations()
|
||||
->where('ip', $server->allocation->ip)
|
||||
->where('ip', $resolvedIp)
|
||||
->where('port', $port)
|
||||
->firstOrFail();
|
||||
|
||||
|
||||
@@ -68,15 +68,29 @@ class DeleteBackupService
|
||||
protected function deleteFromS3(Backup $backup): void
|
||||
{
|
||||
$this->connection->transaction(function () use ($backup) {
|
||||
$backup->delete();
|
||||
|
||||
/** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */
|
||||
$adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3);
|
||||
|
||||
$adapter->getClient()->deleteObject([
|
||||
'Bucket' => $adapter->getBucket(),
|
||||
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
|
||||
]);
|
||||
$s3Key = sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid);
|
||||
|
||||
// First delete from S3, then from database to prevent orphaned records
|
||||
try {
|
||||
$adapter->getClient()->deleteObject([
|
||||
'Bucket' => $adapter->getBucket(),
|
||||
'Key' => $s3Key,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Log S3 deletion failure but continue with database cleanup
|
||||
\Log::warning('Failed to delete backup from S3, continuing with database cleanup', [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'server_uuid' => $backup->server->uuid,
|
||||
's3_key' => $s3Key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Delete from database after S3 cleanup
|
||||
$backup->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace Pterodactyl\Services\Backups;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Webmozart\Assert\Assert;
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Pterodactyl\Models\Server;
|
||||
@@ -13,6 +12,7 @@ use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
|
||||
use Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class InitiateBackupService
|
||||
{
|
||||
@@ -29,6 +29,7 @@ class InitiateBackupService
|
||||
private DaemonBackupRepository $daemonBackupRepository,
|
||||
private DeleteBackupService $deleteBackupService,
|
||||
private BackupManager $backupManager,
|
||||
private ServerStateService $serverStateService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -75,15 +76,13 @@ class InitiateBackupService
|
||||
*/
|
||||
public function handle(Server $server, ?string $name = null, bool $override = false): Backup
|
||||
{
|
||||
$limit = config('backups.throttles.limit');
|
||||
$period = config('backups.throttles.period');
|
||||
if ($period > 0) {
|
||||
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period);
|
||||
if ($previous->count() >= $limit) {
|
||||
$message = sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period);
|
||||
|
||||
throw new TooManyRequestsHttpException((int) CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), $message);
|
||||
}
|
||||
// Validate server state before creating backup
|
||||
$this->validateServerForBackup($server);
|
||||
|
||||
// Check for existing backups in progress (only allow one at a time)
|
||||
$inProgressBackups = $this->repository->getBackupsInProgress($server->id);
|
||||
if ($inProgressBackups->count() > 0) {
|
||||
throw new TooManyRequestsHttpException(30, 'A backup is already in progress. Please wait for it to complete before starting another.');
|
||||
}
|
||||
|
||||
// Check if the server has reached or exceeded its backup limit.
|
||||
@@ -108,21 +107,57 @@ class InitiateBackupService
|
||||
}
|
||||
|
||||
return $this->connection->transaction(function () use ($server, $name) {
|
||||
// Sanitize backup name to prevent injection
|
||||
$backupName = trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString());
|
||||
$backupName = preg_replace('/[^a-zA-Z0-9\s\-_\.]/', '', $backupName);
|
||||
$backupName = substr($backupName, 0, 191); // Limit to database field length
|
||||
|
||||
$serverState = $this->serverStateService->captureServerState($server);
|
||||
|
||||
/** @var Backup $backup */
|
||||
$backup = $this->repository->create([
|
||||
'server_id' => $server->id,
|
||||
'uuid' => Uuid::uuid4()->toString(),
|
||||
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
|
||||
'name' => $backupName,
|
||||
'ignored_files' => array_values($this->ignoredFiles ?? []),
|
||||
'disk' => $this->backupManager->getDefaultAdapter(),
|
||||
'is_locked' => $this->isLocked,
|
||||
'server_state' => $serverState,
|
||||
], true, true);
|
||||
|
||||
$this->daemonBackupRepository->setServer($server)
|
||||
->setBackupAdapter($this->backupManager->getDefaultAdapter())
|
||||
->backup($backup);
|
||||
try {
|
||||
$this->daemonBackupRepository->setServer($server)
|
||||
->setBackupAdapter($this->backupManager->getDefaultAdapter())
|
||||
->backup($backup);
|
||||
} catch (\Exception $e) {
|
||||
// If daemon backup request fails, clean up the backup record
|
||||
$backup->delete();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $backup;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the server is in a valid state for backup creation
|
||||
*/
|
||||
private function validateServerForBackup(Server $server): void
|
||||
{
|
||||
if ($server->isSuspended()) {
|
||||
throw new TooManyBackupsException(0, 'Cannot create backup for suspended server.');
|
||||
}
|
||||
|
||||
if (!$server->isInstalled()) {
|
||||
throw new TooManyBackupsException(0, 'Cannot create backup for server that is not fully installed.');
|
||||
}
|
||||
|
||||
if ($server->status === Server::STATUS_RESTORING_BACKUP) {
|
||||
throw new TooManyBackupsException(0, 'Cannot create backup while server is restoring from another backup.');
|
||||
}
|
||||
|
||||
if ($server->transfer) {
|
||||
throw new TooManyBackupsException(0, 'Cannot create backup while server is being transferred.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
248
app/Services/Backups/ServerStateService.php
Normal file
248
app/Services/Backups/ServerStateService.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Services\Backups;
|
||||
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
class ServerStateService
|
||||
{
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the current server state for backup.
|
||||
* This includes nest_id, egg_id, startup command, docker image, and all configured variables.
|
||||
*/
|
||||
public function captureServerState(Server $server): array
|
||||
{
|
||||
// Load server with relationships needed for state capture
|
||||
$server->load(['variables', 'egg', 'nest']);
|
||||
|
||||
// Capture basic server configuration
|
||||
$state = [
|
||||
'nest_id' => $server->nest_id,
|
||||
'egg_id' => $server->egg_id,
|
||||
'startup' => $server->startup,
|
||||
'image' => $server->image,
|
||||
'captured_at' => now()->toISOString(),
|
||||
];
|
||||
|
||||
// Capture all server variables with their current values
|
||||
$variables = [];
|
||||
foreach ($server->variables as $variable) {
|
||||
$variables[] = [
|
||||
'variable_id' => $variable->id,
|
||||
'env_variable' => $variable->env_variable,
|
||||
'name' => $variable->name,
|
||||
'description' => $variable->description,
|
||||
'default_value' => $variable->default_value,
|
||||
'user_viewable' => $variable->user_viewable,
|
||||
'user_editable' => $variable->user_editable,
|
||||
'rules' => $variable->rules,
|
||||
'server_value' => $variable->server_value, // Current configured value
|
||||
];
|
||||
}
|
||||
$state['variables'] = $variables;
|
||||
|
||||
// Capture egg information for reference
|
||||
if ($server->egg) {
|
||||
$state['egg_info'] = [
|
||||
'name' => $server->egg->name,
|
||||
'description' => $server->egg->description,
|
||||
'uuid' => $server->egg->uuid,
|
||||
'docker_images' => $server->egg->docker_images,
|
||||
];
|
||||
}
|
||||
|
||||
// Capture nest information for reference
|
||||
if ($server->nest) {
|
||||
$state['nest_info'] = [
|
||||
'name' => $server->nest->name,
|
||||
'description' => $server->nest->description,
|
||||
'uuid' => $server->nest->uuid,
|
||||
];
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores server state from a backup.
|
||||
* This will update the server's configuration to match the backup state.
|
||||
*/
|
||||
public function restoreServerState(Server $server, Backup $backup): void
|
||||
{
|
||||
if (empty($backup->server_state)) {
|
||||
// Backup doesn't contain server state (backward compatibility)
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $backup->server_state;
|
||||
|
||||
$this->connection->transaction(function () use ($server, $state) {
|
||||
// Update basic server configuration
|
||||
$serverUpdates = [];
|
||||
|
||||
if (isset($state['nest_id'])) {
|
||||
$serverUpdates['nest_id'] = $state['nest_id'];
|
||||
}
|
||||
|
||||
if (isset($state['egg_id'])) {
|
||||
$serverUpdates['egg_id'] = $state['egg_id'];
|
||||
}
|
||||
|
||||
if (isset($state['startup'])) {
|
||||
$serverUpdates['startup'] = $state['startup'];
|
||||
}
|
||||
|
||||
if (isset($state['image'])) {
|
||||
$serverUpdates['image'] = $state['image'];
|
||||
}
|
||||
|
||||
if (!empty($serverUpdates)) {
|
||||
$server->update($serverUpdates);
|
||||
}
|
||||
|
||||
// Restore server variables
|
||||
if (isset($state['variables']) && is_array($state['variables'])) {
|
||||
$this->restoreServerVariables($server, $state['variables']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores server variables from backup state.
|
||||
*/
|
||||
private function restoreServerVariables(Server $server, array $variables): void
|
||||
{
|
||||
// First, clear existing server variables to ensure clean state
|
||||
$this->connection->table('server_variables')
|
||||
->where('server_id', $server->id)
|
||||
->delete();
|
||||
|
||||
// Restore variables from backup state
|
||||
foreach ($variables as $variable) {
|
||||
if (!isset($variable['env_variable'], $variable['server_value'])) {
|
||||
continue; // Skip invalid variable data
|
||||
}
|
||||
|
||||
// Find the current egg variable by environment variable name
|
||||
// We use env_variable as the key since variable IDs might have changed
|
||||
$currentEggVariable = $this->connection->table('egg_variables')
|
||||
->where('egg_id', $server->egg_id)
|
||||
->where('env_variable', $variable['env_variable'])
|
||||
->first();
|
||||
|
||||
if ($currentEggVariable) {
|
||||
// Insert server variable with the restored value
|
||||
$this->connection->table('server_variables')->insert([
|
||||
'server_id' => $server->id,
|
||||
'variable_id' => $currentEggVariable->id,
|
||||
'variable_value' => $variable['server_value'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the server state can be restored.
|
||||
* Checks if the target nest and egg still exist.
|
||||
*/
|
||||
public function validateRestoreCompatibility(Backup $backup): array
|
||||
{
|
||||
$warnings = [];
|
||||
$errors = [];
|
||||
|
||||
if (empty($backup->server_state)) {
|
||||
return ['warnings' => [], 'errors' => []];
|
||||
}
|
||||
|
||||
$state = $backup->server_state;
|
||||
|
||||
// Check if nest still exists
|
||||
if (isset($state['nest_id'])) {
|
||||
$nestExists = $this->connection->table('nests')
|
||||
->where('id', $state['nest_id'])
|
||||
->exists();
|
||||
|
||||
if (!$nestExists) {
|
||||
$nestName = $state['nest_info']['name'] ?? 'Unknown';
|
||||
$errors[] = "Nest '{$nestName}' (ID: {$state['nest_id']}) no longer exists.";
|
||||
}
|
||||
}
|
||||
|
||||
// Check if egg still exists
|
||||
if (isset($state['egg_id'])) {
|
||||
$eggExists = $this->connection->table('eggs')
|
||||
->where('id', $state['egg_id'])
|
||||
->exists();
|
||||
|
||||
if (!$eggExists) {
|
||||
$eggName = $state['egg_info']['name'] ?? 'Unknown';
|
||||
$errors[] = "Egg '{$eggName}' (ID: {$state['egg_id']}) no longer exists.";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing variables
|
||||
if (isset($state['variables']) && is_array($state['variables'])) {
|
||||
$missingVariables = [];
|
||||
|
||||
foreach ($state['variables'] as $variable) {
|
||||
if (!isset($variable['env_variable'])) continue;
|
||||
|
||||
$exists = $this->connection->table('egg_variables')
|
||||
->where('egg_id', $state['egg_id'] ?? 0)
|
||||
->where('env_variable', $variable['env_variable'])
|
||||
->exists();
|
||||
|
||||
if (!$exists) {
|
||||
$missingVariables[] = $variable['name'] ?? $variable['env_variable'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingVariables)) {
|
||||
$warnings[] = 'Some variables from the backup no longer exist in the current egg: ' . implode(', ', $missingVariables);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'warnings' => $warnings,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a backup has server state data.
|
||||
*/
|
||||
public function hasServerState(Backup $backup): bool
|
||||
{
|
||||
return !empty($backup->server_state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a summary of the server state for display purposes.
|
||||
*/
|
||||
public function getStateSummary(Backup $backup): ?array
|
||||
{
|
||||
if (!$this->hasServerState($backup)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = $backup->server_state;
|
||||
|
||||
return [
|
||||
'nest_name' => $state['nest_info']['name'] ?? 'Unknown',
|
||||
'egg_name' => $state['egg_info']['name'] ?? 'Unknown',
|
||||
'image' => $state['image'] ?? 'Unknown',
|
||||
'variables_count' => count($state['variables'] ?? []),
|
||||
'captured_at' => $state['captured_at'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
106
app/Services/Captcha/CaptchaManager.php
Normal file
106
app/Services/Captcha/CaptchaManager.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Services\Captcha;
|
||||
|
||||
use Illuminate\Support\Manager;
|
||||
use Pterodactyl\Services\Captcha\Providers\TurnstileProvider;
|
||||
use Pterodactyl\Services\Captcha\Providers\HCaptchaProvider;
|
||||
use Pterodactyl\Services\Captcha\Providers\RecaptchaProvider;
|
||||
use Pterodactyl\Services\Captcha\Providers\NullProvider;
|
||||
use Pterodactyl\Contracts\Repository\SettingsRepositoryInterface;
|
||||
|
||||
class CaptchaManager extends Manager
|
||||
{
|
||||
protected SettingsRepositoryInterface $settings;
|
||||
|
||||
public function __construct($app, SettingsRepositoryInterface $settings)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default driver name.
|
||||
*/
|
||||
public function getDefaultDriver(): string
|
||||
{
|
||||
return $this->settings->get('settings::pterodactyl:captcha:provider', 'none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the null captcha driver (no captcha).
|
||||
*/
|
||||
public function createNoneDriver(): NullProvider
|
||||
{
|
||||
return new NullProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Turnstile captcha driver.
|
||||
*/
|
||||
public function createTurnstileDriver(): TurnstileProvider
|
||||
{
|
||||
return new TurnstileProvider([
|
||||
'site_key' => config('pterodactyl.captcha.turnstile.site_key', ''),
|
||||
'secret_key' => config('pterodactyl.captcha.turnstile.secret_key', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the hCaptcha captcha driver.
|
||||
*/
|
||||
public function createHcaptchaDriver(): HCaptchaProvider
|
||||
{
|
||||
return new HCaptchaProvider([
|
||||
'site_key' => config('pterodactyl.captcha.hcaptcha.site_key', ''),
|
||||
'secret_key' => config('pterodactyl.captcha.hcaptcha.secret_key', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the reCAPTCHA captcha driver.
|
||||
*/
|
||||
public function createRecaptchaDriver(): RecaptchaProvider
|
||||
{
|
||||
return new RecaptchaProvider([
|
||||
'site_key' => config('pterodactyl.captcha.recaptcha.site_key', ''),
|
||||
'secret_key' => config('pterodactyl.captcha.recaptcha.secret_key', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the captcha widget HTML.
|
||||
*/
|
||||
public function getWidget(): string
|
||||
{
|
||||
if ($this->getDefaultDriver() === 'none') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->driver()->getWidget('default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a captcha response.
|
||||
*/
|
||||
public function verify(string $response, ?string $remoteIp = null): bool
|
||||
{
|
||||
if ($this->getDefaultDriver() === 'none') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->driver()->verify($response, $remoteIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the JavaScript includes needed for the captcha.
|
||||
*/
|
||||
public function getScriptIncludes(): array
|
||||
{
|
||||
if ($this->getDefaultDriver() === 'none') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->driver()->getScriptIncludes();
|
||||
}
|
||||
}
|
||||
128
app/Services/Captcha/Providers/HCaptchaProvider.php
Normal file
128
app/Services/Captcha/Providers/HCaptchaProvider.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Services\Captcha\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Pterodactyl\Contracts\Captcha\CaptchaProviderInterface;
|
||||
|
||||
class HCaptchaProvider implements CaptchaProviderInterface
|
||||
{
|
||||
protected string $siteKey;
|
||||
protected string $secretKey;
|
||||
protected string $verifyUrl = 'https://api.hcaptcha.com/siteverify';
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->siteKey = $config['site_key'] ?? '';
|
||||
$this->secretKey = $config['secret_key'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTML widget for the captcha.
|
||||
*/
|
||||
public function getWidget(string $form): string
|
||||
{
|
||||
if (empty($this->siteKey)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<div class="h-captcha" data-sitekey="%s" data-callback="onHCaptchaSuccess" data-error-callback="onHCaptchaError" data-theme="auto" data-size="normal"></div>',
|
||||
htmlspecialchars($this->siteKey, ENT_QUOTES, 'UTF-8')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a captcha response.
|
||||
*/
|
||||
public function verify(string $response, ?string $remoteIp = null): bool
|
||||
{
|
||||
if (empty($this->secretKey) || empty($response)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = [
|
||||
'secret' => $this->secretKey,
|
||||
'response' => $response,
|
||||
];
|
||||
|
||||
if ($remoteIp) {
|
||||
$data['remoteip'] = $remoteIp;
|
||||
}
|
||||
|
||||
$httpResponse = Http::timeout(10)
|
||||
->asForm()
|
||||
->post($this->verifyUrl, $data);
|
||||
|
||||
if (!$httpResponse->successful()) {
|
||||
Log::warning('hCaptcha verification failed: HTTP ' . $httpResponse->status());
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $httpResponse->json();
|
||||
|
||||
if (!isset($result['success'])) {
|
||||
Log::warning('hCaptcha verification failed: Invalid response format');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$result['success'] && isset($result['error-codes'])) {
|
||||
Log::warning('hCaptcha verification failed', [
|
||||
'error_codes' => $result['error-codes'],
|
||||
]);
|
||||
}
|
||||
|
||||
return (bool) $result['success'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('hCaptcha verification exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the JavaScript includes needed for this captcha provider.
|
||||
*/
|
||||
public function getScriptIncludes(): array
|
||||
{
|
||||
return [
|
||||
'https://js.hcaptcha.com/1/api.js',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider name.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'hcaptcha';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site key for frontend use.
|
||||
*/
|
||||
public function getSiteKey(): string
|
||||
{
|
||||
return $this->siteKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provider is properly configured.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return !empty($this->siteKey) && !empty($this->secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response field name for this provider.
|
||||
*/
|
||||
public function getResponseFieldName(): string
|
||||
{
|
||||
return 'h-captcha-response';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user