PLEASE BE EVERYTHING :SOB: IT'S BEEN HOOOUORS

This commit is contained in:
Naterfute
2025-09-12 19:50:23 -07:00
441 changed files with 31713 additions and 18354 deletions

View File

@@ -5,4 +5,5 @@ vagrant/
nix/
flake.nix
flake.lock
var/
srv/

36
.gitattributes vendored
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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.
[![Dashboard Image](https://i.imgur.com/kHHOW6P.jpeg)]
![Dashboard Image](https://i.imgur.com/kHHOW6P.jpeg)
## 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
View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

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

View 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.
*/
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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' => [

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

@@ -22,6 +22,7 @@ class StoreNodeRequest extends ApplicationApiRequest
'name',
'location_id',
'fqdn',
'internal_fqdn',
'scheme',
'behind_proxy',
'maintenance_mode',

View File

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

View File

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

View File

@@ -14,6 +14,6 @@ class RestoreBackupRequest extends ClientApiRequest
public function rules(): array
{
return ['truncate' => 'required|boolean'];
return [];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View 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
{
//
}
}

View File

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

View 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
{
//
}
}

View File

@@ -65,6 +65,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton('extensions.themes', function () {
return new Theme();
});
}
/**

View File

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

View 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
{
//
}
}

View File

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

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

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

View File

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

View 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
{
//
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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