merge: sync upstream/master preserving local changes
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# Xboard protocol fusion entrypoint.
|
||||||
|
#
|
||||||
|
# Caddy listens on a single public port and dispatches HTTP traffic to Octane
|
||||||
|
# while transparently upgrading WebSocket requests to the ws-server worker.
|
||||||
|
# This lets every external reverse proxy (nginx, Cloudflare, the user's own
|
||||||
|
# Caddy, ...) treat the panel as a single upstream and avoids exposing the
|
||||||
|
# 8076 WebSocket port directly.
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
auto_https off
|
||||||
|
persist_config off
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
servers {
|
||||||
|
trusted_proxies static 0.0.0.0/0 ::/0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:{$CADDY_LISTEN_PORT:7001} {
|
||||||
|
@ws path /ws
|
||||||
|
reverse_proxy @ws 127.0.0.1:{$WS_PORT:8076}
|
||||||
|
|
||||||
|
reverse_proxy 127.0.0.1:{$OCTANE_INTERNAL_PORT:7002} {
|
||||||
|
header_up Host {host}
|
||||||
|
# X-Forwarded-For is auto-appended with our remote_addr by Caddy
|
||||||
|
# (enabled by the global trusted_proxies above), so Octane receives the
|
||||||
|
# full proxy chain and Laravel's TrustProxies middleware resolves the
|
||||||
|
# real client IP using its own trust list. We additionally surface the
|
||||||
|
# directly-connected peer as X-Real-IP for downstream consumers (logs,
|
||||||
|
# admin tools) that read it directly without TrustProxies.
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Caddy config used by compose.split.yaml — the embedded image's Caddy is
|
||||||
|
# disabled in this mode and a dedicated Caddy container fronts independent
|
||||||
|
# web and ws-server containers reachable via the compose network.
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
auto_https off
|
||||||
|
persist_config off
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
servers {
|
||||||
|
trusted_proxies static 0.0.0.0/0 ::/0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:7001 {
|
||||||
|
@ws path /ws
|
||||||
|
reverse_proxy @ws ws-server:8076
|
||||||
|
|
||||||
|
reverse_proxy web:7001 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Resolve the binding scheme based on whether the embedded Caddy is enabled.
|
||||||
|
#
|
||||||
|
# When ENABLE_CADDY=true (default), Caddy owns the public port (7001) and
|
||||||
|
# dispatches traffic internally; Octane and ws-server bind to localhost only
|
||||||
|
# so they cannot be reached from outside the container.
|
||||||
|
#
|
||||||
|
# When ENABLE_CADDY=false (e.g. an external reverse proxy or split mode),
|
||||||
|
# Octane takes the public port directly to keep behaviour identical to the
|
||||||
|
# pre-Caddy releases.
|
||||||
|
if [ "${ENABLE_CADDY}" = "true" ]; then
|
||||||
|
: "${OCTANE_HOST:=127.0.0.1}"
|
||||||
|
: "${OCTANE_PORT:=7002}"
|
||||||
|
: "${WS_HOST:=127.0.0.1}"
|
||||||
|
: "${WS_PORT:=8076}"
|
||||||
|
: "${CADDY_LISTEN_PORT:=7001}"
|
||||||
|
else
|
||||||
|
: "${OCTANE_HOST:=0.0.0.0}"
|
||||||
|
: "${OCTANE_PORT:=7001}"
|
||||||
|
: "${WS_HOST:=0.0.0.0}"
|
||||||
|
: "${WS_PORT:=8076}"
|
||||||
|
fi
|
||||||
|
export OCTANE_HOST OCTANE_PORT WS_HOST WS_PORT CADDY_LISTEN_PORT
|
||||||
|
export OCTANE_INTERNAL_PORT="${OCTANE_PORT}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auto-tune worker counts based on the host (CPU + memory).
|
||||||
|
#
|
||||||
|
# Heuristic: each PHP worker (Octane/Horizon) costs ~80 MiB. After reserving
|
||||||
|
# ~300 MiB for the always-on processes (caddy/redis/ws-server/masters), divide
|
||||||
|
# the remaining budget across roles. Any user-set ENV wins.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
detect_cpus() {
|
||||||
|
if [ -r /sys/fs/cgroup/cpu.max ]; then
|
||||||
|
# cgroup v2: "<quota> <period>" or "max <period>"
|
||||||
|
read -r q p < /sys/fs/cgroup/cpu.max 2>/dev/null
|
||||||
|
if [ "$q" != "max" ] && [ -n "$q" ] && [ -n "$p" ] && [ "$p" -gt 0 ]; then
|
||||||
|
echo $(( (q + p - 1) / p ))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
nproc 2>/dev/null || echo 1
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_mem_mib() {
|
||||||
|
if [ -r /sys/fs/cgroup/memory.max ]; then
|
||||||
|
m=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
|
||||||
|
if [ "$m" != "max" ] && [ -n "$m" ]; then
|
||||||
|
echo $(( m / 1024 / 1024 ))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# No cgroup limit: avoid over-provisioning on big hosts. Cap the assumed
|
||||||
|
# budget to MEM_FALLBACK_MIB (default 1024) unless the user opts out by
|
||||||
|
# setting it explicitly. Use whichever is smaller of MemAvailable and cap.
|
||||||
|
avail=$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 1024)
|
||||||
|
cap=${MEM_FALLBACK_MIB:-1024}
|
||||||
|
[ "$avail" -lt "$cap" ] && echo "$avail" || echo "$cap"
|
||||||
|
}
|
||||||
|
|
||||||
|
CPUS=$(detect_cpus)
|
||||||
|
MEM_MIB=$(detect_mem_mib)
|
||||||
|
|
||||||
|
# Resource profile presets. RESOURCE_PROFILE selects ratios for the budget split:
|
||||||
|
# minimal - smallest possible footprint (~250-350 MiB), single octane worker,
|
||||||
|
# horizon capped to 1/1/1. Suitable for VPS with <=512 MiB RAM.
|
||||||
|
# balanced - default; ~80 MiB per worker, octane gets 25% of slots.
|
||||||
|
# performance - larger reserves for opcache/caches, more aggressive horizon caps.
|
||||||
|
# auto - same as balanced.
|
||||||
|
: "${RESOURCE_PROFILE:=auto}"
|
||||||
|
case "$RESOURCE_PROFILE" in
|
||||||
|
minimal) RESERVED_MIB=200; SLOT_MIB=100; OCT_NUM=1; OCT_DEN=1; OCT_FORCE=1; auto_horizon_mem=128; auto_octane_gc=64 ;;
|
||||||
|
performance) RESERVED_MIB=400; SLOT_MIB=70; OCT_NUM=1; OCT_DEN=3; OCT_FORCE=0; auto_horizon_mem=384; auto_octane_gc=256 ;;
|
||||||
|
balanced|auto|*) RESERVED_MIB=300; SLOT_MIB=80; OCT_NUM=1; OCT_DEN=4; OCT_FORCE=0; auto_horizon_mem=256; auto_octane_gc=128 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
BUDGET=$(( MEM_MIB - RESERVED_MIB ))
|
||||||
|
[ "$BUDGET" -lt "$SLOT_MIB" ] && BUDGET=$SLOT_MIB
|
||||||
|
SLOTS=$(( BUDGET / SLOT_MIB ))
|
||||||
|
|
||||||
|
clamp() { v=$1; lo=$2; hi=$3; [ "$v" -lt "$lo" ] && v=$lo; [ "$v" -gt "$hi" ] && v=$hi; echo "$v"; }
|
||||||
|
|
||||||
|
if [ "$OCT_FORCE" = "1" ]; then
|
||||||
|
auto_octane=1
|
||||||
|
auto_dp=1; auto_biz=1; auto_notif=1
|
||||||
|
else
|
||||||
|
auto_octane=$(clamp $(( (SLOTS * OCT_NUM) / OCT_DEN )) 1 "$CPUS")
|
||||||
|
remaining=$(( SLOTS - auto_octane - 2 ))
|
||||||
|
[ "$remaining" -lt 3 ] && remaining=3
|
||||||
|
auto_dp=$(clamp $(( remaining / 2 )) 1 $(( CPUS * 2 )))
|
||||||
|
auto_biz=$(clamp $(( remaining / 4 )) 1 "$CPUS")
|
||||||
|
auto_notif=$(clamp $(( remaining / 4 )) 1 "$CPUS")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# User-set ENV always wins.
|
||||||
|
: "${OCTANE_WORKERS:=$auto_octane}"
|
||||||
|
: "${OCTANE_TASK_WORKERS:=1}"
|
||||||
|
: "${OCTANE_MAX_REQUESTS:=500}"
|
||||||
|
: "${OCTANE_GARBAGE_MB:=$auto_octane_gc}"
|
||||||
|
: "${OCTANE_MAX_EXECUTION_TIME:=60}"
|
||||||
|
: "${HORIZON_DATA_PIPELINE_MAX:=$auto_dp}"
|
||||||
|
: "${HORIZON_BUSINESS_MAX:=$auto_biz}"
|
||||||
|
: "${HORIZON_NOTIFICATION_MAX:=$auto_notif}"
|
||||||
|
: "${HORIZON_WORKER_MEMORY_MB:=$auto_horizon_mem}"
|
||||||
|
: "${HORIZON_WORKER_MAX_TIME:=0}"
|
||||||
|
: "${HORIZON_WORKER_MAX_JOBS:=0}"
|
||||||
|
|
||||||
|
export OCTANE_WORKERS OCTANE_TASK_WORKERS OCTANE_MAX_REQUESTS \
|
||||||
|
OCTANE_GARBAGE_MB OCTANE_MAX_EXECUTION_TIME \
|
||||||
|
HORIZON_DATA_PIPELINE_MAX HORIZON_BUSINESS_MAX HORIZON_NOTIFICATION_MAX \
|
||||||
|
HORIZON_WORKER_MEMORY_MB HORIZON_WORKER_MAX_TIME HORIZON_WORKER_MAX_JOBS \
|
||||||
|
RESOURCE_PROFILE
|
||||||
|
|
||||||
|
echo "[entrypoint] Auto-tune (profile=${RESOURCE_PROFILE}): cpus=${CPUS} mem=${MEM_MIB}MiB slots=${SLOTS} -> octane=${OCTANE_WORKERS} horizon(dp/biz/notif)=${HORIZON_DATA_PIPELINE_MAX}/${HORIZON_BUSINESS_MAX}/${HORIZON_NOTIFICATION_MAX} horizon_worker_mem=${HORIZON_WORKER_MEMORY_MB}MB"
|
||||||
|
echo "[entrypoint] Horizon supervisors use balance=auto with minProcesses=1, so they scale up to the cap on demand and back down when idle."
|
||||||
|
|
||||||
|
redis_reachable() {
|
||||||
|
local host port
|
||||||
|
host=$(grep -E '^REDIS_HOST=' /www/.env 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
|
||||||
|
port=$(grep -E '^REDIS_PORT=' /www/.env 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
|
||||||
|
command -v redis-cli >/dev/null 2>&1 || return 1
|
||||||
|
[ -n "$host" ] || return 1
|
||||||
|
case "$host" in
|
||||||
|
/*) [ -S "$host" ] && redis-cli -s "$host" ping 2>/dev/null | grep -q PONG ;;
|
||||||
|
*) redis-cli -h "$host" -p "${port:-6379}" ping 2>/dev/null | grep -q PONG ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -s /www/.env ] || ! grep -qE '^INSTALLED=(1|true)$' /www/.env || echo " $* " | grep -q ' xboard:install '; then
|
||||||
|
echo "[entrypoint] Skipping xboard:update (not yet installed or running xboard:install)."
|
||||||
|
else
|
||||||
|
if redis_reachable; then
|
||||||
|
echo "[entrypoint] Running xboard:update (redis reachable, real drivers)..."
|
||||||
|
php /www/artisan xboard:update --no-interaction || \
|
||||||
|
echo "[entrypoint] WARNING: xboard:update failed; continuing so supervisor can boot anyway." >&2
|
||||||
|
else
|
||||||
|
echo "[entrypoint] Running xboard:update (redis not yet up, using array/sync drivers)..."
|
||||||
|
CACHE_DRIVER=array QUEUE_CONNECTION=sync SESSION_DRIVER=array \
|
||||||
|
php /www/artisan xboard:update --no-interaction || \
|
||||||
|
echo "[entrypoint] WARNING: xboard:update failed; continuing so supervisor can boot anyway." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] Starting services (caddy=${ENABLE_CADDY} web=${ENABLE_WEB} horizon=${ENABLE_HORIZON} ws=${ENABLE_WS_SERVER})..."
|
||||||
|
# Drop stale Octane/WorkerMan state files so the new master does not signal
|
||||||
|
# PIDs left over from a previous container run (causes Swoole kill EPERM).
|
||||||
|
rm -f /www/storage/logs/octane-server-state.json /www/storage/logs/xboard-ws-server.pid 2>/dev/null || true
|
||||||
|
chown -R www:www /www 2>/dev/null || true
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
; Slim PHP defaults for the all-in-one container.
|
||||||
|
; Tunables are overridable via Docker ENV (PHP_MEMORY_LIMIT, etc.) if needed.
|
||||||
|
|
||||||
|
memory_limit = 256M
|
||||||
|
|
||||||
|
[opcache]
|
||||||
|
opcache.enable = 1
|
||||||
|
opcache.enable_cli = 0
|
||||||
|
opcache.memory_consumption = 96
|
||||||
|
opcache.interned_strings_buffer = 16
|
||||||
|
opcache.max_accelerated_files = 10000
|
||||||
|
opcache.validate_timestamps = 0
|
||||||
|
opcache.revalidate_freq = 0
|
||||||
|
opcache.fast_shutdown = 1
|
||||||
|
|
||||||
|
[swoole]
|
||||||
|
swoole.use_shortname = Off
|
||||||
@@ -8,7 +8,7 @@ loglevel=info
|
|||||||
|
|
||||||
[program:octane]
|
[program:octane]
|
||||||
process_name=%(program_name)s_%(process_num)02d
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
command=php /www/artisan octane:start --host=0.0.0.0 --port=7001
|
command=php /www/artisan octane:start --host=%(ENV_OCTANE_HOST)s --port=%(ENV_OCTANE_PORT)s --workers=%(ENV_OCTANE_WORKERS)s --task-workers=%(ENV_OCTANE_TASK_WORKERS)s --max-requests=%(ENV_OCTANE_MAX_REQUESTS)s
|
||||||
autostart=%(ENV_ENABLE_WEB)s
|
autostart=%(ENV_ENABLE_WEB)s
|
||||||
autorestart=true
|
autorestart=true
|
||||||
user=www
|
user=www
|
||||||
@@ -47,6 +47,7 @@ command=redis-server --dir /data
|
|||||||
--save 900 1
|
--save 900 1
|
||||||
--save 300 10
|
--save 300 10
|
||||||
--save 60 10000
|
--save 60 10000
|
||||||
|
--port 0
|
||||||
--unixsocket /data/redis.sock
|
--unixsocket /data/redis.sock
|
||||||
--unixsocketperm 777
|
--unixsocketperm 777
|
||||||
autostart=%(ENV_ENABLE_REDIS)s
|
autostart=%(ENV_ENABLE_REDIS)s
|
||||||
@@ -65,7 +66,7 @@ priority=300
|
|||||||
|
|
||||||
[program:ws-server]
|
[program:ws-server]
|
||||||
process_name=%(program_name)s_%(process_num)02d
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
command=php /www/artisan ws-server start
|
command=php /www/artisan ws-server start --host=%(ENV_WS_HOST)s --port=%(ENV_WS_PORT)s
|
||||||
autostart=%(ENV_ENABLE_WS_SERVER)s
|
autostart=%(ENV_ENABLE_WS_SERVER)s
|
||||||
autorestart=true
|
autorestart=true
|
||||||
user=www
|
user=www
|
||||||
@@ -79,3 +80,20 @@ stopsignal=SIGINT
|
|||||||
stopasgroup=true
|
stopasgroup=true
|
||||||
killasgroup=true
|
killasgroup=true
|
||||||
priority=400
|
priority=400
|
||||||
|
|
||||||
|
[program:caddy]
|
||||||
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
|
command=caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||||
|
autostart=%(ENV_ENABLE_CADDY)s
|
||||||
|
autorestart=true
|
||||||
|
user=root
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stdout_logfile_backups=0
|
||||||
|
numprocs=1
|
||||||
|
stopwaitsecs=5
|
||||||
|
stopsignal=TERM
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
priority=500
|
||||||
@@ -11,13 +11,11 @@
|
|||||||
.env.backup
|
.env.backup
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
.idea
|
.idea
|
||||||
.lock
|
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
composer.phar
|
composer.phar
|
||||||
composer.lock
|
|
||||||
yarn.lock
|
yarn.lock
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+10
-5
@@ -7,7 +7,7 @@ RUN CFLAGS="-O0" install-php-extensions pcntl && \
|
|||||||
CFLAGS="-O0 -g0" install-php-extensions bcmath && \
|
CFLAGS="-O0 -g0" install-php-extensions bcmath && \
|
||||||
install-php-extensions zip && \
|
install-php-extensions zip && \
|
||||||
install-php-extensions redis && \
|
install-php-extensions redis && \
|
||||||
apk --no-cache add shadow sqlite mysql-client mysql-dev mariadb-connector-c git patch supervisor redis && \
|
apk --no-cache add shadow sqlite mysql-client mysql-dev mariadb-connector-c git patch supervisor redis caddy && \
|
||||||
addgroup -S -g 1000 www && adduser -S -G www -u 1000 www && \
|
addgroup -S -g 1000 www && adduser -S -G www -u 1000 www && \
|
||||||
(getent group redis || addgroup -S redis) && \
|
(getent group redis || addgroup -S redis) && \
|
||||||
(getent passwd redis || adduser -S -G redis -H -h /data redis)
|
(getent passwd redis || adduser -S -G redis -H -h /data redis)
|
||||||
@@ -19,10 +19,11 @@ COPY .docker /
|
|||||||
COPY . /www
|
COPY . /www
|
||||||
|
|
||||||
COPY .docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY .docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
COPY .docker/caddy/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY .docker/php/zz-xboard.ini /usr/local/etc/php/conf.d/zz-xboard.ini
|
||||||
|
|
||||||
RUN composer install --no-cache --no-dev \
|
RUN composer install --no-cache --no-dev --no-security-blocking \
|
||||||
&& php artisan storage:link \
|
&& php artisan storage:link \
|
||||||
&& cp -r plugins/ /opt/default-plugins/ \
|
|
||||||
&& chown -R www:www /www \
|
&& chown -R www:www /www \
|
||||||
&& chmod -R 775 /www \
|
&& chmod -R 775 /www \
|
||||||
&& mkdir -p /data \
|
&& mkdir -p /data \
|
||||||
@@ -30,8 +31,12 @@ RUN composer install --no-cache --no-dev \
|
|||||||
|
|
||||||
ENV ENABLE_WEB=true \
|
ENV ENABLE_WEB=true \
|
||||||
ENABLE_HORIZON=true \
|
ENABLE_HORIZON=true \
|
||||||
ENABLE_REDIS=false \
|
ENABLE_REDIS=true \
|
||||||
ENABLE_WS_SERVER=false
|
ENABLE_WS_SERVER=true \
|
||||||
|
ENABLE_CADDY=true
|
||||||
|
|
||||||
EXPOSE 7001
|
EXPOSE 7001
|
||||||
|
COPY .docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ docker compose run -it --rm \
|
|||||||
-e ENABLE_SQLITE=true \
|
-e ENABLE_SQLITE=true \
|
||||||
-e ENABLE_REDIS=true \
|
-e ENABLE_REDIS=true \
|
||||||
-e ADMIN_ACCOUNT=admin@demo.com \
|
-e ADMIN_ACCOUNT=admin@demo.com \
|
||||||
web php artisan xboard:install && \
|
xboard php artisan xboard:install && \
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class CheckTicket extends Command
|
|||||||
{
|
{
|
||||||
Ticket::where('status', 0)
|
Ticket::where('status', 0)
|
||||||
->where('updated_at', '<=', time() - 24 * 3600)
|
->where('updated_at', '<=', time() - 24 * 3600)
|
||||||
->where('reply_status', 0)
|
->where('reply_status', Ticket::REPLY_STATUS_REPLIED)
|
||||||
->lazyById(200)
|
->lazyById(200)
|
||||||
->each(function ($ticket) {
|
->each(function ($ticket) {
|
||||||
if ($ticket->user_id === $ticket->last_reply_user_id) return;
|
if ($ticket->user_id === $ticket->last_reply_user_id) return;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CleanupOnlineStatus extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'cleanup:online-status';
|
||||||
|
|
||||||
|
protected $description = 'Reset stale online_count for users whose devices have expired from Redis';
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$affected = User::where('online_count', '>', 0)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->where('last_online_at', '<', now()->subMinutes(10))
|
||||||
|
->orWhereNull('last_online_at');
|
||||||
|
})
|
||||||
|
->update(['online_count' => 0]);
|
||||||
|
|
||||||
|
if ($affected > 0) {
|
||||||
|
$this->info("Reset online_count for {$affected} stale users.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ class HookList extends Command
|
|||||||
|
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$paths = [base_path('app'), base_path('plugins')];
|
$paths = [base_path('app'), base_path('plugins-core'), base_path('plugins')];
|
||||||
$hooks = collect();
|
$hooks = collect();
|
||||||
$pattern = '/HookManager::(call|filter|register|registerFilter)\([\'\"]([a-zA-Z0-9_.-]+)[\'\"]/';
|
$pattern = '/HookManager::(call|filter|register|registerFilter)\([\'\"]([a-zA-Z0-9_.-]+)[\'\"]/';
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ use function Laravel\Prompts\confirm;
|
|||||||
use function Laravel\Prompts\text;
|
use function Laravel\Prompts\text;
|
||||||
use function Laravel\Prompts\note;
|
use function Laravel\Prompts\note;
|
||||||
use function Laravel\Prompts\select;
|
use function Laravel\Prompts\select;
|
||||||
use App\Models\Plugin;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class XboardInstall extends Command
|
class XboardInstall extends Command
|
||||||
{
|
{
|
||||||
@@ -103,15 +101,17 @@ class XboardInstall extends Command
|
|||||||
$isReidsValid = false;
|
$isReidsValid = false;
|
||||||
while (!$isReidsValid) {
|
while (!$isReidsValid) {
|
||||||
// 判断是否为Docker环境
|
// 判断是否为Docker环境
|
||||||
if ($isDocker == 'true' && ($enableRedis || confirm(label: '是否启用Docker内置的Redis', default: true, yes: '启用', no: '不启用'))) {
|
$useBuiltinRedis = $isDocker && ($enableRedis || confirm(label: '是否启用Docker内置的Redis', default: true, yes: '启用', no: '不启用'));
|
||||||
|
if ($useBuiltinRedis) {
|
||||||
$envConfig['REDIS_HOST'] = '/data/redis.sock';
|
$envConfig['REDIS_HOST'] = '/data/redis.sock';
|
||||||
$envConfig['REDIS_PORT'] = 0;
|
$envConfig['REDIS_PORT'] = 0;
|
||||||
$envConfig['REDIS_PASSWORD'] = null;
|
$envConfig['REDIS_PASSWORD'] = null;
|
||||||
} else {
|
$isReidsValid = true;
|
||||||
$envConfig['REDIS_HOST'] = text(label: '请输入Redis地址', default: '127.0.0.1', required: true);
|
break;
|
||||||
$envConfig['REDIS_PORT'] = text(label: '请输入Redis端口', default: '6379', required: true);
|
|
||||||
$envConfig['REDIS_PASSWORD'] = text(label: '请输入redis密码(默认: null)', default: '');
|
|
||||||
}
|
}
|
||||||
|
$envConfig['REDIS_HOST'] = text(label: '请输入Redis地址', default: '127.0.0.1', required: true);
|
||||||
|
$envConfig['REDIS_PORT'] = text(label: '请输入Redis端口', default: '6379', required: true);
|
||||||
|
$envConfig['REDIS_PASSWORD'] = text(label: '请输入redis密码(默认: null)', default: '');
|
||||||
$redisConfig = [
|
$redisConfig = [
|
||||||
'client' => 'phpredis',
|
'client' => 'phpredis',
|
||||||
'default' => [
|
'default' => [
|
||||||
@@ -150,6 +150,20 @@ class XboardInstall extends Command
|
|||||||
$password = Helper::guid(false);
|
$password = Helper::guid(false);
|
||||||
$this->saveToEnv($envConfig);
|
$this->saveToEnv($envConfig);
|
||||||
|
|
||||||
|
$installDriverOverrides = [
|
||||||
|
'CACHE_DRIVER' => 'array',
|
||||||
|
'QUEUE_CONNECTION' => 'sync',
|
||||||
|
'SESSION_DRIVER' => 'array',
|
||||||
|
];
|
||||||
|
foreach ($installDriverOverrides as $key => $value) {
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
}
|
||||||
|
Config::set('cache.default', 'array');
|
||||||
|
Config::set('queue.default', 'sync');
|
||||||
|
Config::set('session.driver', 'array');
|
||||||
|
|
||||||
$this->call('config:cache');
|
$this->call('config:cache');
|
||||||
Artisan::call('cache:clear');
|
Artisan::call('cache:clear');
|
||||||
$this->info('正在导入数据库请稍等...');
|
$this->info('正在导入数据库请稍等...');
|
||||||
@@ -160,7 +174,6 @@ class XboardInstall extends Command
|
|||||||
if (!self::registerAdmin($email, $password)) {
|
if (!self::registerAdmin($email, $password)) {
|
||||||
abort(500, '管理员账号注册失败,请重试');
|
abort(500, '管理员账号注册失败,请重试');
|
||||||
}
|
}
|
||||||
self::restoreProtectedPlugins($this);
|
|
||||||
$this->info('正在安装默认插件...');
|
$this->info('正在安装默认插件...');
|
||||||
PluginManager::installDefaultPlugins();
|
PluginManager::installDefaultPlugins();
|
||||||
$this->info('默认插件安装完成');
|
$this->info('默认插件安装完成');
|
||||||
@@ -173,6 +186,11 @@ class XboardInstall extends Command
|
|||||||
$this->info("访问 http(s)://你的站点/{$defaultSecurePath} 进入管理面板,你可以在用户中心修改你的密码。");
|
$this->info("访问 http(s)://你的站点/{$defaultSecurePath} 进入管理面板,你可以在用户中心修改你的密码。");
|
||||||
$envConfig['INSTALLED'] = true;
|
$envConfig['INSTALLED'] = true;
|
||||||
$this->saveToEnv($envConfig);
|
$this->saveToEnv($envConfig);
|
||||||
|
foreach (array_keys($installDriverOverrides) as $key) {
|
||||||
|
putenv($key);
|
||||||
|
unset($_ENV[$key], $_SERVER[$key]);
|
||||||
|
}
|
||||||
|
Artisan::call('config:clear');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error($e);
|
$this->error($e);
|
||||||
}
|
}
|
||||||
@@ -364,34 +382,4 @@ class XboardInstall extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 还原内置受保护插件(可在安装和更新时调用)
|
|
||||||
* Docker 部署时 plugins/ 目录被外部挂载覆盖,需要从镜像备份中还原默认插件
|
|
||||||
*/
|
|
||||||
public static function restoreProtectedPlugins(Command $console = null)
|
|
||||||
{
|
|
||||||
$backupBase = '/opt/default-plugins';
|
|
||||||
$pluginsBase = base_path('plugins');
|
|
||||||
|
|
||||||
if (!File::isDirectory($backupBase)) {
|
|
||||||
$console?->info('非 Docker 环境或备份目录不存在,跳过插件还原。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
|
|
||||||
$dirName = Str::studly($pluginCode);
|
|
||||||
$source = "{$backupBase}/{$dirName}";
|
|
||||||
$target = "{$pluginsBase}/{$dirName}";
|
|
||||||
|
|
||||||
if (!File::isDirectory($source)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先清除旧文件再复制,避免重命名后残留旧文件
|
|
||||||
File::deleteDirectory($target);
|
|
||||||
File::copyDirectory($source, $target);
|
|
||||||
$console?->info("已同步默认插件 [{$dirName}]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ use App\Services\UpdateService;
|
|||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use App\Services\Plugin\PluginManager;
|
use App\Services\Plugin\PluginManager;
|
||||||
use App\Models\Plugin;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use App\Console\Commands\XboardInstall;
|
|
||||||
|
|
||||||
class XboardUpdate extends Command
|
class XboardUpdate extends Command
|
||||||
{
|
{
|
||||||
@@ -47,19 +44,22 @@ class XboardUpdate extends Command
|
|||||||
$this->info('正在导入数据库请稍等...');
|
$this->info('正在导入数据库请稍等...');
|
||||||
Artisan::call("migrate", ['--force' => true]);
|
Artisan::call("migrate", ['--force' => true]);
|
||||||
$this->info(Artisan::output());
|
$this->info(Artisan::output());
|
||||||
$this->info('正在检查内置插件文件...');
|
|
||||||
XboardInstall::restoreProtectedPlugins($this);
|
|
||||||
$this->info('正在检查并安装默认插件...');
|
$this->info('正在检查并安装默认插件...');
|
||||||
PluginManager::installDefaultPlugins();
|
PluginManager::installDefaultPlugins();
|
||||||
$this->info('默认插件检查完成');
|
$this->info('默认插件检查完成');
|
||||||
// Artisan::call('reset:traffic', ['--fix-null' => true]);
|
|
||||||
$this->info('正在重新计算所有用户的重置时间...');
|
|
||||||
Artisan::call('reset:traffic', ['--force' => true]);
|
|
||||||
$updateService = new UpdateService();
|
$updateService = new UpdateService();
|
||||||
$updateService->updateVersionCache();
|
$updateService->updateVersionCache();
|
||||||
$themeService = app(ThemeService::class);
|
$themeService = app(ThemeService::class);
|
||||||
$themeService->refreshCurrentTheme();
|
$themeService->refreshCurrentTheme();
|
||||||
Artisan::call('horizon:terminate');
|
if (config('queue.default') === 'sync') {
|
||||||
|
$this->info('horizon:terminate skipped (sync queue, no workers to terminate).');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Artisan::call('horizon:terminate');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warn('horizon:terminate skipped: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
$this->info('更新完毕,队列服务已重启,你无需进行任何操作。');
|
$this->info('更新完毕,队列服务已重启,你无需进行任何操作。');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class Kernel extends ConsoleKernel
|
|||||||
$schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer();
|
$schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer();
|
||||||
// horizon metrics
|
// horizon metrics
|
||||||
$schedule->command('horizon:snapshot')->everyFiveMinutes()->onOneServer();
|
$schedule->command('horizon:snapshot')->everyFiveMinutes()->onOneServer();
|
||||||
|
// cleanup stale online_count (GC for Redis TTL expiration)
|
||||||
|
$schedule->command('cleanup:online-status')->everyFiveMinutes()->onOneServer();
|
||||||
// backup Timing
|
// backup Timing
|
||||||
// if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) {
|
// if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) {
|
||||||
// $schedule->command('backup:database', ['true'])->daily()->onOneServer();
|
// $schedule->command('backup:database', ['true'])->daily()->onOneServer();
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ class ConfigController extends Controller
|
|||||||
'server_ws_url' => admin_setting('server_ws_url', ''),
|
'server_ws_url' => admin_setting('server_ws_url', ''),
|
||||||
],
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'email_template' => admin_setting('email_template', 'default'),
|
|
||||||
'email_host' => admin_setting('email_host'),
|
'email_host' => admin_setting('email_host'),
|
||||||
'email_port' => admin_setting('email_port'),
|
'email_port' => admin_setting('email_port'),
|
||||||
'email_username' => admin_setting('email_username'),
|
'email_username' => admin_setting('email_username'),
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\V2\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MailTemplate;
|
||||||
|
use App\Services\MailService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class MailTemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function list()
|
||||||
|
{
|
||||||
|
$dbTemplates = MailTemplate::all()->keyBy('name');
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach (MailTemplate::TEMPLATES as $name => $meta) {
|
||||||
|
$db = $dbTemplates->get($name);
|
||||||
|
$result[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'label' => $meta['label'],
|
||||||
|
'customized' => $db !== null,
|
||||||
|
'subject' => $db?->subject,
|
||||||
|
'updated_at' => $db?->updated_at?->timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(Request $request)
|
||||||
|
{
|
||||||
|
$name = $request->input('name');
|
||||||
|
$meta = MailTemplate::getMeta($name);
|
||||||
|
if (!$meta) {
|
||||||
|
return $this->fail([404, '模板不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = MailTemplate::where('name', $name)->first();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'name' => $name,
|
||||||
|
'label' => $meta['label'],
|
||||||
|
'required_vars' => $meta['required_vars'],
|
||||||
|
'optional_vars' => $meta['optional_vars'],
|
||||||
|
'customized' => $db !== null,
|
||||||
|
'subject' => $db?->subject ?? $this->getDefaultSubject($name),
|
||||||
|
'content' => $db?->content ?? $this->getDefaultContent($name),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Request $request)
|
||||||
|
{
|
||||||
|
$params = $request->validate([
|
||||||
|
'name' => 'required|string',
|
||||||
|
'subject' => 'required|string|max:255',
|
||||||
|
'content' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$meta = MailTemplate::getMeta($params['name']);
|
||||||
|
if (!$meta) {
|
||||||
|
return $this->fail([404, '模板不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = MailTemplate::validateContent($params['name'], $params['content']);
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return $this->fail([422, implode('; ', $errors)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
MailTemplate::updateOrCreate(
|
||||||
|
['name' => $params['name']],
|
||||||
|
['subject' => $params['subject'], 'content' => $params['content']]
|
||||||
|
);
|
||||||
|
Cache::forget("mail_template:{$params['name']}");
|
||||||
|
|
||||||
|
return $this->success(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(Request $request)
|
||||||
|
{
|
||||||
|
$name = $request->input('name');
|
||||||
|
$meta = MailTemplate::getMeta($name);
|
||||||
|
if (!$meta) {
|
||||||
|
return $this->fail([404, '模板不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
MailTemplate::where('name', $name)->delete();
|
||||||
|
Cache::forget("mail_template:{$name}");
|
||||||
|
return $this->success(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test(Request $request)
|
||||||
|
{
|
||||||
|
$name = $request->input('name');
|
||||||
|
$meta = MailTemplate::getMeta($name);
|
||||||
|
if (!$meta) {
|
||||||
|
return $this->fail([404, '模板不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = $request->input('email', $request->user()->email);
|
||||||
|
$testVars = $this->getTestVars($name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$log = MailService::sendEmail([
|
||||||
|
'email' => $email,
|
||||||
|
'subject' => $this->getTestSubject($name),
|
||||||
|
'template_name' => $name,
|
||||||
|
'template_value' => $testVars,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($log['error']) {
|
||||||
|
return $this->fail([500, '发送失败: ' . $log['error']]);
|
||||||
|
}
|
||||||
|
return $this->success(true);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error($e);
|
||||||
|
return $this->fail([500, '发送失败: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTestSubject(string $name): string
|
||||||
|
{
|
||||||
|
$appName = admin_setting('app_name', 'XBoard');
|
||||||
|
return match ($name) {
|
||||||
|
'verify' => "{$appName} - 验证码测试",
|
||||||
|
'notify' => "{$appName} - 通知测试",
|
||||||
|
'remindExpire' => "{$appName} - 到期提醒测试",
|
||||||
|
'remindTraffic' => "{$appName} - 流量提醒测试",
|
||||||
|
'mailLogin' => "{$appName} - 登录链接测试",
|
||||||
|
default => "{$appName} - 邮件测试",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTestVars(string $name): array
|
||||||
|
{
|
||||||
|
$appName = admin_setting('app_name', 'XBoard');
|
||||||
|
$appUrl = admin_setting('app_url', 'https://example.com');
|
||||||
|
|
||||||
|
return match ($name) {
|
||||||
|
'verify' => [
|
||||||
|
'name' => $appName,
|
||||||
|
'code' => '123456',
|
||||||
|
'url' => $appUrl,
|
||||||
|
],
|
||||||
|
'notify' => [
|
||||||
|
'name' => $appName,
|
||||||
|
'content' => '这是一封测试通知邮件。',
|
||||||
|
'url' => $appUrl,
|
||||||
|
],
|
||||||
|
'remindExpire' => [
|
||||||
|
'name' => $appName,
|
||||||
|
'url' => $appUrl,
|
||||||
|
],
|
||||||
|
'remindTraffic' => [
|
||||||
|
'name' => $appName,
|
||||||
|
'url' => $appUrl,
|
||||||
|
],
|
||||||
|
'mailLogin' => [
|
||||||
|
'name' => $appName,
|
||||||
|
'link' => $appUrl . '/login?token=test-token',
|
||||||
|
'url' => $appUrl,
|
||||||
|
],
|
||||||
|
default => ['name' => $appName, 'url' => $appUrl],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDefaultSubject(string $name): string
|
||||||
|
{
|
||||||
|
$appName = admin_setting('app_name', 'XBoard');
|
||||||
|
return match ($name) {
|
||||||
|
'verify' => "{$appName} - 邮箱验证码",
|
||||||
|
'notify' => "{$appName} - 站点通知",
|
||||||
|
'remindExpire' => "{$appName} - 服务即将到期",
|
||||||
|
'remindTraffic' => "{$appName} - 流量使用提醒",
|
||||||
|
'mailLogin' => "{$appName} - 邮件登录",
|
||||||
|
default => "{$appName}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDefaultContent(string $name): string
|
||||||
|
{
|
||||||
|
$theme = 'default';
|
||||||
|
$viewName = "mail.{$theme}.{$name}";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$viewPath = resource_path("views/mail/{$theme}/{$name}.blade.php");
|
||||||
|
if (file_exists($viewPath)) {
|
||||||
|
$blade = file_get_contents($viewPath);
|
||||||
|
return self::bladeToPlaceholder($blade);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::hardcodedDefault($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Blade syntax to {{placeholder}} syntax for editing.
|
||||||
|
*/
|
||||||
|
private static function bladeToPlaceholder(string $blade): string
|
||||||
|
{
|
||||||
|
// {{$var}} → {{var}}
|
||||||
|
$result = preg_replace('/\{\{\s*\$([a-zA-Z_]+)\s*\}\}/', '{{$1}}', $blade);
|
||||||
|
// {!! nl2br($var) !!} → {{var}}
|
||||||
|
$result = preg_replace('/\{!!\s*nl2br\(\$([a-zA-Z_]+)\)\s*!!\}/', '{{$1}}', $result);
|
||||||
|
// {!! $var !!} → {{var}}
|
||||||
|
$result = preg_replace('/\{!!\s*\$([a-zA-Z_]+)\s*!!\}/', '{{$1}}', $result);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hardcodedDefault(string $name): string
|
||||||
|
{
|
||||||
|
$layout = fn($title, $body) => <<<HTML
|
||||||
|
<div style="background: #eee">
|
||||||
|
<table width="600" border="0" align="center" cellpadding="0" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="background:#fff">
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td valign="middle" style="padding-left:30px;background-color:#415A94;color:#fff;padding:20px 40px;font-size: 21px;">{{name}}</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="padding:40px 40px 0 40px;display:table-cell">
|
||||||
|
<td style="font-size:24px;line-height:1.5;color:#000;margin-top:40px">{$title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
|
||||||
|
尊敬的用户您好!<br /><br />{$body}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 40px;font-size:12px;color:#999;line-height:20px;background:#f7f7f7"><a href="{{url}}" style="font-size:14px;color:#929292">返回{{name}}</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
return match ($name) {
|
||||||
|
'verify' => $layout('邮箱验证码', '您的验证码是:{{code}},请在 5 分钟内进行验证。如果该验证码不为您本人申请,请无视。'),
|
||||||
|
'notify' => $layout('网站通知', '{{content}}'),
|
||||||
|
'remindExpire' => $layout('服务到期提醒', '您的服务即将在24小时内到期,如需继续使用请及时续费。'),
|
||||||
|
'remindTraffic' => $layout('流量使用提醒', '您的流量使用已达到80%,请注意流量使用情况。'),
|
||||||
|
'mailLogin' => $layout('登入到{{name}}', '您正在登入到{{name}}, 请在 5 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。<a href="{{link}}">{{link}}</a>'),
|
||||||
|
default => $layout('通知', '{{content}}'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ class PaymentController extends Controller
|
|||||||
|
|
||||||
public function fetch()
|
public function fetch()
|
||||||
{
|
{
|
||||||
$payments = Payment::orderBy('sort', 'ASC')->get();
|
$payments = Payment::orderBy('sort', 'ASC')->get()->makeVisible('config');
|
||||||
foreach ($payments as $k => $v) {
|
foreach ($payments as $k => $v) {
|
||||||
$notifyUrl = url("/api/v1/guest/payment/notify/{$v->payment}/{$v->uuid}");
|
$notifyUrl = url("/api/v1/guest/payment/notify/{$v->payment}/{$v->uuid}");
|
||||||
if ($v->notify_domain) {
|
if ($v->notify_domain) {
|
||||||
|
|||||||
@@ -60,56 +60,67 @@ class PluginController extends Controller
|
|||||||
->keyBy('code')
|
->keyBy('code')
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$pluginPath = base_path('plugins');
|
|
||||||
$plugins = [];
|
$plugins = [];
|
||||||
|
$seenCodes = [];
|
||||||
|
|
||||||
if (File::exists($pluginPath)) {
|
foreach ($this->pluginManager->getPluginPaths() as $pluginPath) {
|
||||||
|
if (!File::exists($pluginPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$directories = File::directories($pluginPath);
|
$directories = File::directories($pluginPath);
|
||||||
foreach ($directories as $directory) {
|
foreach ($directories as $directory) {
|
||||||
$pluginName = basename($directory);
|
|
||||||
$configFile = $directory . '/config.json';
|
$configFile = $directory . '/config.json';
|
||||||
if (File::exists($configFile)) {
|
if (!File::exists($configFile)) {
|
||||||
$config = json_decode(File::get($configFile), true);
|
continue;
|
||||||
$code = $config['code'];
|
|
||||||
$pluginType = $config['type'] ?? Plugin::TYPE_FEATURE;
|
|
||||||
|
|
||||||
// 如果指定了类型,过滤插件
|
|
||||||
if ($type && $pluginType !== $type) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$installed = isset($installedPlugins[$code]);
|
|
||||||
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
|
|
||||||
$readmeFile = collect(['README.md', 'readme.md'])
|
|
||||||
->map(fn($f) => $directory . '/' . $f)
|
|
||||||
->first(fn($path) => File::exists($path));
|
|
||||||
$readmeContent = $readmeFile ? File::get($readmeFile) : '';
|
|
||||||
$needUpgrade = false;
|
|
||||||
if ($installed) {
|
|
||||||
$installedVersion = $installedPlugins[$code]['version'] ?? null;
|
|
||||||
$localVersion = $config['version'] ?? null;
|
|
||||||
if ($installedVersion && $localVersion && version_compare($localVersion, $installedVersion, '>')) {
|
|
||||||
$needUpgrade = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$plugins[] = [
|
|
||||||
'code' => $config['code'],
|
|
||||||
'name' => $config['name'],
|
|
||||||
'version' => $config['version'],
|
|
||||||
'description' => $config['description'],
|
|
||||||
'author' => $config['author'],
|
|
||||||
'type' => $pluginType,
|
|
||||||
'is_installed' => $installed,
|
|
||||||
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
|
|
||||||
'is_protected' => in_array($code, Plugin::PROTECTED_PLUGINS),
|
|
||||||
'can_be_deleted' => !in_array($code, Plugin::PROTECTED_PLUGINS),
|
|
||||||
'config' => $pluginConfig,
|
|
||||||
'readme' => $readmeContent,
|
|
||||||
'need_upgrade' => $needUpgrade,
|
|
||||||
'admin_menus' => $config['admin_menus'] ?? null,
|
|
||||||
'admin_crud' => $config['admin_crud'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
$config = json_decode(File::get($configFile), true);
|
||||||
|
if (!$config || !isset($config['code'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$code = $config['code'];
|
||||||
|
|
||||||
|
if (isset($seenCodes[$code])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenCodes[$code] = true;
|
||||||
|
|
||||||
|
$pluginType = $config['type'] ?? Plugin::TYPE_FEATURE;
|
||||||
|
if ($type && $pluginType !== $type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$installed = isset($installedPlugins[$code]);
|
||||||
|
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
|
||||||
|
$readmeFile = collect(['README.md', 'readme.md'])
|
||||||
|
->map(fn($f) => $directory . '/' . $f)
|
||||||
|
->first(fn($path) => File::exists($path));
|
||||||
|
$readmeContent = $readmeFile ? File::get($readmeFile) : '';
|
||||||
|
$needUpgrade = false;
|
||||||
|
if ($installed) {
|
||||||
|
$installedVersion = $installedPlugins[$code]['version'] ?? null;
|
||||||
|
$localVersion = $config['version'] ?? null;
|
||||||
|
if ($installedVersion && $localVersion && version_compare($localVersion, $installedVersion, '>')) {
|
||||||
|
$needUpgrade = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$isCore = $this->pluginManager->isCorePlugin($code);
|
||||||
|
$plugins[] = [
|
||||||
|
'code' => $config['code'],
|
||||||
|
'name' => $config['name'],
|
||||||
|
'version' => $config['version'],
|
||||||
|
'description' => $config['description'],
|
||||||
|
'author' => $config['author'],
|
||||||
|
'type' => $pluginType,
|
||||||
|
'is_installed' => $installed,
|
||||||
|
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
|
||||||
|
'is_protected' => $isCore,
|
||||||
|
'can_be_deleted' => !$isCore,
|
||||||
|
'config' => $pluginConfig,
|
||||||
|
'readme' => $readmeContent,
|
||||||
|
'need_upgrade' => $needUpgrade,
|
||||||
|
'admin_menus' => $config['admin_menus'] ?? null,
|
||||||
|
'admin_crud' => $config['admin_crud'] ?? null,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,10 +325,10 @@ class PluginController extends Controller
|
|||||||
|
|
||||||
$code = $request->input('code');
|
$code = $request->input('code');
|
||||||
|
|
||||||
// 检查是否为受保护的插件
|
// 检查是否为核心插件
|
||||||
if (in_array($code, Plugin::PROTECTED_PLUGINS)) {
|
if ($this->pluginManager->isCorePlugin($code)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => '该插件为系统默认插件,不允许删除'
|
'message' => '该插件为系统核心插件,不允许删除'
|
||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,12 +168,19 @@ class MachineController extends Controller
|
|||||||
$params = $request->validate([
|
$params = $request->validate([
|
||||||
'machine_id' => 'required|integer|exists:v2_server_machine,id',
|
'machine_id' => 'required|integer|exists:v2_server_machine,id',
|
||||||
'limit' => 'nullable|integer|min:10|max:1440',
|
'limit' => 'nullable|integer|min:10|max:1440',
|
||||||
|
'range_hours' => 'nullable|integer|min:1|max:24',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$query = ServerMachineLoadHistory::query()
|
||||||
|
->where('machine_id', $params['machine_id']);
|
||||||
|
|
||||||
|
if (!empty($params['range_hours'])) {
|
||||||
|
$query->where('recorded_at', '>=', now()->subHours((int) $params['range_hours'])->timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
$limit = (int) ($params['limit'] ?? 60);
|
$limit = (int) ($params['limit'] ?? 60);
|
||||||
|
|
||||||
$history = ServerMachineLoadHistory::query()
|
$history = $query
|
||||||
->where('machine_id', $params['machine_id'])
|
|
||||||
->orderByDesc('recorded_at')
|
->orderByDesc('recorded_at')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get([
|
->get([
|
||||||
@@ -182,6 +189,8 @@ class MachineController extends Controller
|
|||||||
'mem_used',
|
'mem_used',
|
||||||
'disk_total',
|
'disk_total',
|
||||||
'disk_used',
|
'disk_used',
|
||||||
|
'net_in_speed',
|
||||||
|
'net_out_speed',
|
||||||
'recorded_at',
|
'recorded_at',
|
||||||
])
|
])
|
||||||
->reverse()
|
->reverse()
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ class ManageController extends Controller
|
|||||||
'ids' => 'required|array',
|
'ids' => 'required|array',
|
||||||
'ids.*' => 'integer',
|
'ids.*' => 'integer',
|
||||||
'show' => 'nullable|integer|in:0,1',
|
'show' => 'nullable|integer|in:0,1',
|
||||||
|
'enabled' => 'nullable|boolean',
|
||||||
|
'machine_id' => 'nullable|integer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$ids = $params['ids'];
|
$ids = $params['ids'];
|
||||||
@@ -236,13 +238,25 @@ class ManageController extends Controller
|
|||||||
if (array_key_exists('show', $params) && $params['show'] !== null) {
|
if (array_key_exists('show', $params) && $params['show'] !== null) {
|
||||||
$update['show'] = (int) $params['show'];
|
$update['show'] = (int) $params['show'];
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('enabled', $params) && $params['enabled'] !== null) {
|
||||||
|
$update['enabled'] = (bool) $params['enabled'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('machine_id', $params)) {
|
||||||
|
$update['machine_id'] = $params['machine_id'] ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($update)) {
|
if (empty($update)) {
|
||||||
return $this->fail([400, '没有可更新的字段']);
|
return $this->fail([400, '没有可更新的字段']);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Server::whereIn('id', $ids)->update($update);
|
$servers = Server::whereIn('id', $ids)->get();
|
||||||
|
DB::transaction(function () use ($servers, $update) {
|
||||||
|
/** @var Server $server */
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
$server->update($update);
|
||||||
|
}
|
||||||
|
});
|
||||||
return $this->success(true);
|
return $this->success(true);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error($e);
|
Log::error($e);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class TicketController extends Controller
|
|||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
return $this->fail([400202, '工单不存在']);
|
return $this->fail([400202, '工单不存在']);
|
||||||
}
|
}
|
||||||
|
$ticket->messages->each(fn($msg) => $msg->setRelation('ticket', $ticket));
|
||||||
$result = $ticket->toArray();
|
$result = $ticket->toArray();
|
||||||
$result['user'] = UserController::transformUserData($ticket->user);
|
$result['user'] = UserController::transformUserData($ticket->user);
|
||||||
|
|
||||||
@@ -144,11 +145,12 @@ class TicketController extends Controller
|
|||||||
$ticket = Ticket::with([
|
$ticket = Ticket::with([
|
||||||
'user',
|
'user',
|
||||||
'messages' => function ($query) {
|
'messages' => function ($query) {
|
||||||
$query->with(['user']); // 如果需要用户信息
|
$query->with(['user']);
|
||||||
}
|
}
|
||||||
])->findOrFail($ticketId);
|
])->findOrFail($ticketId);
|
||||||
|
|
||||||
// 自动包含 is_me 属性
|
$ticket->messages->each(fn($msg) => $msg->setRelation('ticket', $ticket));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $ticket
|
'data' => $ticket
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -50,32 +50,46 @@ class MachineController extends Controller
|
|||||||
'swap.used' => 'nullable|integer|min:0',
|
'swap.used' => 'nullable|integer|min:0',
|
||||||
'disk.total' => 'nullable|integer|min:0',
|
'disk.total' => 'nullable|integer|min:0',
|
||||||
'disk.used' => 'nullable|integer|min:0',
|
'disk.used' => 'nullable|integer|min:0',
|
||||||
|
'net.in_speed' => 'nullable|numeric|min:0',
|
||||||
|
'net.out_speed' => 'nullable|numeric|min:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$machine = $this->authenticateMachine($request);
|
$machine = $this->authenticateMachine($request);
|
||||||
$recordedAt = now()->timestamp;
|
$recordedAt = now()->timestamp;
|
||||||
|
|
||||||
$machine->forceFill([
|
$loadStatus = [
|
||||||
'load_status' => [
|
'cpu' => (float) $request->input('cpu'),
|
||||||
'cpu' => (float) $request->input('cpu'),
|
'mem' => [
|
||||||
'mem' => [
|
'total' => (int) $request->input('mem.total'),
|
||||||
'total' => (int) $request->input('mem.total'),
|
'used' => (int) $request->input('mem.used'),
|
||||||
'used' => (int) $request->input('mem.used'),
|
|
||||||
],
|
|
||||||
'swap' => [
|
|
||||||
'total' => (int) $request->input('swap.total', 0),
|
|
||||||
'used' => (int) $request->input('swap.used', 0),
|
|
||||||
],
|
|
||||||
'disk' => [
|
|
||||||
'total' => (int) $request->input('disk.total', 0),
|
|
||||||
'used' => (int) $request->input('disk.used', 0),
|
|
||||||
],
|
|
||||||
'updated_at' => $recordedAt,
|
|
||||||
],
|
],
|
||||||
|
'swap' => [
|
||||||
|
'total' => (int) $request->input('swap.total', 0),
|
||||||
|
'used' => (int) $request->input('swap.used', 0),
|
||||||
|
],
|
||||||
|
'disk' => [
|
||||||
|
'total' => (int) $request->input('disk.total', 0),
|
||||||
|
'used' => (int) $request->input('disk.used', 0),
|
||||||
|
],
|
||||||
|
'updated_at' => $recordedAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
$netInSpeed = $request->input('net.in_speed');
|
||||||
|
$netOutSpeed = $request->input('net.out_speed');
|
||||||
|
|
||||||
|
if ($netInSpeed !== null && $netOutSpeed !== null) {
|
||||||
|
$loadStatus['net'] = [
|
||||||
|
'in_speed' => (float) $netInSpeed,
|
||||||
|
'out_speed' => (float) $netOutSpeed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$machine->forceFill([
|
||||||
|
'load_status' => $loadStatus,
|
||||||
'last_seen_at' => $recordedAt,
|
'last_seen_at' => $recordedAt,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
ServerMachineLoadHistory::create([
|
$historyData = [
|
||||||
'machine_id' => $machine->id,
|
'machine_id' => $machine->id,
|
||||||
'cpu' => (float) $request->input('cpu'),
|
'cpu' => (float) $request->input('cpu'),
|
||||||
'mem_total' => (int) $request->input('mem.total'),
|
'mem_total' => (int) $request->input('mem.total'),
|
||||||
@@ -83,7 +97,14 @@ class MachineController extends Controller
|
|||||||
'disk_total' => (int) $request->input('disk.total', 0),
|
'disk_total' => (int) $request->input('disk.total', 0),
|
||||||
'disk_used' => (int) $request->input('disk.used', 0),
|
'disk_used' => (int) $request->input('disk.used', 0),
|
||||||
'recorded_at' => $recordedAt,
|
'recorded_at' => $recordedAt,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($netInSpeed !== null && $netOutSpeed !== null) {
|
||||||
|
$historyData['net_in_speed'] = (float) $netInSpeed;
|
||||||
|
$historyData['net_out_speed'] = (float) $netOutSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMachineLoadHistory::create($historyData);
|
||||||
|
|
||||||
// Time-based cleanup: keep 24h of data, runs on ~5% of requests
|
// Time-based cleanup: keep 24h of data, runs on ~5% of requests
|
||||||
if (random_int(1, 20) === 1) {
|
if (random_int(1, 20) === 1) {
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ namespace App\Http\Controllers\V2\Server;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\ServerService;
|
use App\Services\ServerService;
|
||||||
|
use App\WebSocket\NodeWorker;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class ServerController extends Controller
|
class ServerController extends Controller
|
||||||
{
|
{
|
||||||
@@ -16,14 +18,14 @@ class ServerController extends Controller
|
|||||||
{
|
{
|
||||||
$websocket = ['enabled' => false];
|
$websocket = ['enabled' => false];
|
||||||
|
|
||||||
if ((bool) admin_setting('server_ws_enable', 1)) {
|
if ((bool) admin_setting('server_ws_enable', 1) && Cache::has(NodeWorker::HEARTBEAT_CACHE_KEY)) {
|
||||||
$customUrl = trim((string) admin_setting('server_ws_url', ''));
|
$customUrl = trim((string) admin_setting('server_ws_url', ''));
|
||||||
|
|
||||||
if ($customUrl !== '') {
|
if ($customUrl !== '') {
|
||||||
$wsUrl = rtrim($customUrl, '/');
|
$wsUrl = rtrim($customUrl, '/');
|
||||||
} else {
|
} else {
|
||||||
$wsScheme = $request->isSecure() ? 'wss' : 'ws';
|
$wsScheme = $request->isSecure() ? 'wss' : 'ws';
|
||||||
$wsUrl = "{$wsScheme}://{$request->getHost()}:8076";
|
$wsUrl = "{$wsScheme}://{$request->getHttpHost()}/ws";
|
||||||
}
|
}
|
||||||
|
|
||||||
$websocket = [
|
$websocket = [
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class Kernel extends HttpKernel
|
|||||||
'staff' => \App\Http\Middleware\Staff::class,
|
'staff' => \App\Http\Middleware\Staff::class,
|
||||||
'log' => \App\Http\Middleware\RequestLog::class,
|
'log' => \App\Http\Middleware\RequestLog::class,
|
||||||
'server' => \App\Http\Middleware\Server::class,
|
'server' => \App\Http\Middleware\Server::class,
|
||||||
|
'server.v2' => \App\Http\Middleware\ServerV2::class,
|
||||||
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
|
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
|
||||||
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
|
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,29 +4,16 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use App\Exceptions\ApiException;
|
use App\Exceptions\ApiException;
|
||||||
use App\Models\Server as ServerModel;
|
use App\Models\Server as ServerModel;
|
||||||
use App\Models\ServerMachine;
|
|
||||||
use App\Services\ServerService;
|
use App\Services\ServerService;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use {@see ServerV2}
|
||||||
|
*/
|
||||||
class Server
|
class Server
|
||||||
{
|
{
|
||||||
public function handle(Request $request, Closure $next, ?string $nodeType = null)
|
public function handle(Request $request, Closure $next, ?string $nodeType = null)
|
||||||
{
|
|
||||||
// 优先尝试 machine token 认证,兜底走旧的 server token 认证
|
|
||||||
if ($request->filled('machine_id')) {
|
|
||||||
$this->authenticateByMachine($request, $nodeType);
|
|
||||||
} else {
|
|
||||||
$this->authenticateByServerToken($request, $nodeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 旧模式:全局 server_token + node_id
|
|
||||||
*/
|
|
||||||
private function authenticateByServerToken(Request $request, ?string $nodeType): void
|
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'token' => [
|
'token' => [
|
||||||
@@ -64,55 +51,7 @@ class Server
|
|||||||
}
|
}
|
||||||
|
|
||||||
$request->attributes->set('node_info', $serverInfo);
|
$request->attributes->set('node_info', $serverInfo);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return $next($request);
|
||||||
* 新模式:machine_id + machine token + node_id
|
|
||||||
*
|
|
||||||
* machine 认证后,node_id 必须属于该 machine 下的已启用节点。
|
|
||||||
* 下游控制器拿到的 node_info 与旧模式完全一致。
|
|
||||||
*/
|
|
||||||
private function authenticateByMachine(Request $request, ?string $nodeType): void
|
|
||||||
{
|
|
||||||
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
|
|
||||||
|
|
||||||
$request->validate([
|
|
||||||
'machine_id' => 'required|integer',
|
|
||||||
'token' => 'required|string',
|
|
||||||
'node_id' => $isHandshake ? 'nullable|integer' : 'required|integer',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$machine = ServerMachine::where('id', $request->input('machine_id'))
|
|
||||||
->where('token', $request->input('token'))
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (!$machine) {
|
|
||||||
throw new ApiException('Machine not found or invalid token', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$machine->is_active) {
|
|
||||||
throw new ApiException('Machine is disabled', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$nodeId = (int) $request->input('node_id');
|
|
||||||
$serverInfo = null;
|
|
||||||
|
|
||||||
if ($nodeId > 0) {
|
|
||||||
$serverInfo = ServerModel::where('id', $nodeId)
|
|
||||||
->where('machine_id', $machine->id)
|
|
||||||
->where('enabled', true)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (!$serverInfo) {
|
|
||||||
throw new ApiException('Node not found on this machine');
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->attributes->set('node_info', $serverInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新机器心跳
|
|
||||||
$machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly();
|
|
||||||
|
|
||||||
$request->attributes->set('machine_info', $machine);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Exceptions\ApiException;
|
||||||
|
use App\Models\Server as ServerModel;
|
||||||
|
use App\Models\ServerMachine;
|
||||||
|
use App\Services\ServerService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 server middleware: machine-token or server-token auth, no node_type.
|
||||||
|
*/
|
||||||
|
class ServerV2
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
if ($request->filled('machine_id')) {
|
||||||
|
$this->authenticateByMachine($request);
|
||||||
|
} else {
|
||||||
|
$this->authenticateByServerToken($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authenticateByServerToken(Request $request): void
|
||||||
|
{
|
||||||
|
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'token' => [
|
||||||
|
'string', 'required',
|
||||||
|
function ($attribute, $value, $fail) {
|
||||||
|
if ($value !== admin_setting('server_token')) {
|
||||||
|
$fail("Invalid {$attribute}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'node_id' => $isHandshake ? 'nullable' : 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nodeId = $request->input('node_id');
|
||||||
|
if ($nodeId === null || $nodeId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverInfo = ServerService::getServer($nodeId);
|
||||||
|
if (!$serverInfo) {
|
||||||
|
throw new ApiException('Server does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('node_info', $serverInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authenticateByMachine(Request $request): void
|
||||||
|
{
|
||||||
|
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'machine_id' => 'required|integer',
|
||||||
|
'token' => 'required|string',
|
||||||
|
'node_id' => $isHandshake ? 'nullable|integer' : 'required|integer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$machine = ServerMachine::where('id', $request->input('machine_id'))
|
||||||
|
->where('token', $request->input('token'))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$machine) {
|
||||||
|
throw new ApiException('Machine not found or invalid token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$machine->is_active) {
|
||||||
|
throw new ApiException('Machine is disabled', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nodeId = (int) $request->input('node_id');
|
||||||
|
if ($nodeId > 0) {
|
||||||
|
$serverInfo = ServerModel::where('id', $nodeId)
|
||||||
|
->where('machine_id', $machine->id)
|
||||||
|
->where('enabled', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$serverInfo) {
|
||||||
|
throw new ApiException('Node not found on this machine');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('node_info', $serverInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly();
|
||||||
|
|
||||||
|
$request->attributes->set('machine_info', $machine);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,6 @@ class ConfigSave extends FormRequest
|
|||||||
'frontend_theme_color' => 'nullable|in:default,darkblue,black,green',
|
'frontend_theme_color' => 'nullable|in:default,darkblue,black,green',
|
||||||
'frontend_background_url' => 'nullable|url',
|
'frontend_background_url' => 'nullable|url',
|
||||||
// email
|
// email
|
||||||
'email_template' => '',
|
|
||||||
'email_host' => '',
|
'email_host' => '',
|
||||||
'email_port' => '',
|
'email_port' => '',
|
||||||
'email_username' => '',
|
'email_username' => '',
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class ServerSave extends FormRequest
|
|||||||
'tls' => 'required|integer',
|
'tls' => 'required|integer',
|
||||||
'network' => 'required|string',
|
'network' => 'required|string',
|
||||||
'network_settings' => 'nullable|array',
|
'network_settings' => 'nullable|array',
|
||||||
|
'rules' => 'nullable|array',
|
||||||
],
|
],
|
||||||
'trojan' => [
|
'trojan' => [
|
||||||
'tls' => 'nullable|integer',
|
'tls' => 'nullable|integer',
|
||||||
@@ -91,6 +92,12 @@ class ServerSave extends FormRequest
|
|||||||
'http' => [
|
'http' => [
|
||||||
'tls' => 'required|integer',
|
'tls' => 'required|integer',
|
||||||
],
|
],
|
||||||
|
'tuic' => [
|
||||||
|
'version' => 'nullable|integer',
|
||||||
|
'congestion_control' => 'nullable|string',
|
||||||
|
'alpn' => 'nullable|array',
|
||||||
|
'udp_relay_mode' => 'nullable|string',
|
||||||
|
],
|
||||||
'mieru' => [
|
'mieru' => [
|
||||||
'transport' => 'required|string|in:TCP,UDP',
|
'transport' => 'required|string|in:TCP,UDP',
|
||||||
'traffic_pattern' => 'string',
|
'traffic_pattern' => 'string',
|
||||||
@@ -161,6 +168,10 @@ class ServerSave extends FormRequest
|
|||||||
$rules,
|
$rules,
|
||||||
$this->buildTlsObjectRules(),
|
$this->buildTlsObjectRules(),
|
||||||
),
|
),
|
||||||
|
'mieru' => array_merge(
|
||||||
|
$rules,
|
||||||
|
self::MULTIPLEX_RULES,
|
||||||
|
),
|
||||||
'vless' => array_merge(
|
'vless' => array_merge(
|
||||||
$rules,
|
$rules,
|
||||||
$this->buildTlsSettingsRules(),
|
$this->buildTlsSettingsRules(),
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ class OrderResource extends JsonResource
|
|||||||
...parent::toArray($request),
|
...parent::toArray($request),
|
||||||
'period' => PlanService::getLegacyPeriod((string)$this->period),
|
'period' => PlanService::getLegacyPeriod((string)$this->period),
|
||||||
'plan' => $this->whenLoaded('plan', fn() => PlanResource::make($this->plan)),
|
'plan' => $this->whenLoaded('plan', fn() => PlanResource::make($this->plan)),
|
||||||
|
'payment' => $this->whenLoaded('payment', fn() => $this->payment ? [
|
||||||
|
'id' => $this->payment->id,
|
||||||
|
'name' => $this->payment->name,
|
||||||
|
'payment' => $this->payment->payment,
|
||||||
|
'icon' => $this->payment->icon,
|
||||||
|
] : null),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
namespace App\Http\Routes\V2;
|
namespace App\Http\Routes\V2;
|
||||||
|
|
||||||
use App\Http\Controllers\V2\Admin\ConfigController;
|
use App\Http\Controllers\V2\Admin\ConfigController;
|
||||||
|
use App\Http\Controllers\V2\Admin\MailTemplateController;
|
||||||
use App\Http\Controllers\V2\Admin\PlanController;
|
use App\Http\Controllers\V2\Admin\PlanController;
|
||||||
use App\Http\Controllers\V2\Admin\Server\GroupController;
|
use App\Http\Controllers\V2\Admin\Server\GroupController;
|
||||||
use App\Http\Controllers\V2\Admin\Server\RouteController;
|
use App\Http\Controllers\V2\Admin\Server\RouteController;
|
||||||
@@ -41,6 +42,17 @@ class AdminRoute
|
|||||||
$router->post('/testSendMail', [ConfigController::class, 'testSendMail']);
|
$router->post('/testSendMail', [ConfigController::class, 'testSendMail']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mail Templates
|
||||||
|
$router->group([
|
||||||
|
'prefix' => 'mail/template'
|
||||||
|
], function ($router) {
|
||||||
|
$router->get('/list', [MailTemplateController::class, 'list']);
|
||||||
|
$router->get('/get', [MailTemplateController::class, 'get']);
|
||||||
|
$router->post('/save', [MailTemplateController::class, 'save']);
|
||||||
|
$router->post('/reset', [MailTemplateController::class, 'reset']);
|
||||||
|
$router->post('/test', [MailTemplateController::class, 'test']);
|
||||||
|
});
|
||||||
|
|
||||||
// Plan
|
// Plan
|
||||||
$router->group([
|
$router->group([
|
||||||
'prefix' => 'plan'
|
'prefix' => 'plan'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class ServerRoute
|
|||||||
{
|
{
|
||||||
$router->group([
|
$router->group([
|
||||||
'prefix' => 'server',
|
'prefix' => 'server',
|
||||||
'middleware' => 'server'
|
'middleware' => 'server.v2'
|
||||||
], function ($route) {
|
], function ($route) {
|
||||||
$route->match(['GET', 'POST'], 'handshake', [ServerController::class, 'handshake']);
|
$route->match(['GET', 'POST'], 'handshake', [ServerController::class, 'handshake']);
|
||||||
$route->post('report', [ServerController::class, 'report']);
|
$route->post('report', [ServerController::class, 'report']);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class MailTemplate extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'v2_mail_templates';
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'subject', 'content'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template definitions: required/optional vars and default content.
|
||||||
|
*/
|
||||||
|
public const TEMPLATES = [
|
||||||
|
'verify' => [
|
||||||
|
'label' => '邮箱验证码',
|
||||||
|
'required_vars' => ['code'],
|
||||||
|
'optional_vars' => ['name', 'url'],
|
||||||
|
],
|
||||||
|
'notify' => [
|
||||||
|
'label' => '站点通知',
|
||||||
|
'required_vars' => ['content'],
|
||||||
|
'optional_vars' => ['name', 'url'],
|
||||||
|
],
|
||||||
|
'remindExpire' => [
|
||||||
|
'label' => '到期提醒',
|
||||||
|
'required_vars' => [],
|
||||||
|
'optional_vars' => ['name', 'url'],
|
||||||
|
],
|
||||||
|
'remindTraffic' => [
|
||||||
|
'label' => '流量提醒',
|
||||||
|
'required_vars' => [],
|
||||||
|
'optional_vars' => ['name', 'url'],
|
||||||
|
],
|
||||||
|
'mailLogin' => [
|
||||||
|
'label' => '邮件登录',
|
||||||
|
'required_vars' => ['link'],
|
||||||
|
'optional_vars' => ['name', 'url'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template metadata (vars, label) for a given template name.
|
||||||
|
*/
|
||||||
|
public static function getMeta(string $name): ?array
|
||||||
|
{
|
||||||
|
return self::TEMPLATES[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all template names.
|
||||||
|
*/
|
||||||
|
public static function getNames(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::TEMPLATES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that required placeholders are present in the content.
|
||||||
|
*/
|
||||||
|
public static function validateContent(string $name, string $content): array
|
||||||
|
{
|
||||||
|
$meta = self::getMeta($name);
|
||||||
|
if (!$meta) {
|
||||||
|
return ["Unknown template: {$name}"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
foreach ($meta['required_vars'] as $var) {
|
||||||
|
if (strpos($content, '{{' . $var . '}}') === false) {
|
||||||
|
$errors[] = "缺少必要占位符: {{{$var}}}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,4 +15,8 @@ class Payment extends Model
|
|||||||
'config' => 'array',
|
'config' => 'array',
|
||||||
'enable' => 'boolean'
|
'enable' => 'boolean'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'config',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class ServerMachineLoadHistory extends Model
|
|||||||
'mem_used' => 'integer',
|
'mem_used' => 'integer',
|
||||||
'disk_total' => 'integer',
|
'disk_total' => 'integer',
|
||||||
'disk_used' => 'integer',
|
'disk_used' => 'integer',
|
||||||
|
'net_in_speed' => 'float',
|
||||||
|
'net_out_speed' => 'float',
|
||||||
'recorded_at' => 'integer',
|
'recorded_at' => 'integer',
|
||||||
'created_at' => 'timestamp',
|
'created_at' => 'timestamp',
|
||||||
'updated_at' => 'timestamp',
|
'updated_at' => 'timestamp',
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class Ticket extends Model
|
|||||||
self::STATUS_CLOSED => '关闭'
|
self::STATUS_CLOSED => '关闭'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const REPLY_STATUS_WAITING = 0;
|
||||||
|
const REPLY_STATUS_REPLIED = 1;
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'user_id', 'id');
|
return $this->belongsTo(User::class, 'user_id', 'id');
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class TicketMessage extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $appends = ['is_from_user', 'is_from_admin'];
|
protected $appends = ['is_from_user', 'is_from_admin'];
|
||||||
|
protected $hidden = ['ticket'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关联的工单
|
* 关联的工单
|
||||||
@@ -43,7 +44,7 @@ class TicketMessage extends Model
|
|||||||
*/
|
*/
|
||||||
public function getIsFromUserAttribute(): bool
|
public function getIsFromUserAttribute(): bool
|
||||||
{
|
{
|
||||||
return $this->ticket->user_id === $this->user_id;
|
return $this->ticket && $this->ticket->user_id === $this->user_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +52,6 @@ class TicketMessage extends Model
|
|||||||
*/
|
*/
|
||||||
public function getIsFromAdminAttribute(): bool
|
public function getIsFromAdminAttribute(): bool
|
||||||
{
|
{
|
||||||
return $this->ticket->user_id !== $this->user_id;
|
return $this->ticket && $this->ticket->user_id !== $this->user_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,10 +238,10 @@ class Clash extends AbstractProtocol
|
|||||||
$array['port'] = $server['port'];
|
$array['port'] = $server['port'];
|
||||||
$array['password'] = $password;
|
$array['password'] = $password;
|
||||||
$array['udp'] = true;
|
$array['udp'] = true;
|
||||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||||
$array['sni'] = $serverName;
|
$array['sni'] = $serverName;
|
||||||
}
|
}
|
||||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure');
|
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
|
||||||
|
|
||||||
switch (data_get($protocol_settings, 'network')) {
|
switch (data_get($protocol_settings, 'network')) {
|
||||||
case 'tcp':
|
case 'tcp':
|
||||||
|
|||||||
@@ -36,6 +36,27 @@ class ClashMeta extends AbstractProtocol
|
|||||||
'http' => '0.0.0',
|
'http' => '0.0.0',
|
||||||
'h2' => '0.0.0',
|
'h2' => '0.0.0',
|
||||||
'httpupgrade' => '0.0.0',
|
'httpupgrade' => '0.0.0',
|
||||||
|
'xhttp' => '0.0.0',
|
||||||
|
],
|
||||||
|
'strict' => true,
|
||||||
|
],
|
||||||
|
'*.vmess.protocol_settings.network' => [
|
||||||
|
'whitelist' => [
|
||||||
|
'tcp' => '0.0.0',
|
||||||
|
'ws' => '0.0.0',
|
||||||
|
'grpc' => '0.0.0',
|
||||||
|
'http' => '0.0.0',
|
||||||
|
'h2' => '0.0.0',
|
||||||
|
'httpupgrade' => '0.0.0',
|
||||||
|
],
|
||||||
|
'strict' => true,
|
||||||
|
],
|
||||||
|
'*.trojan.protocol_settings.network' => [
|
||||||
|
'whitelist' => [
|
||||||
|
'tcp' => '0.0.0',
|
||||||
|
'ws' => '0.0.0',
|
||||||
|
'grpc' => '0.0.0',
|
||||||
|
'httpupgrade' => '0.0.0',
|
||||||
],
|
],
|
||||||
'strict' => true,
|
'strict' => true,
|
||||||
],
|
],
|
||||||
@@ -568,6 +589,18 @@ class ClashMeta extends AbstractProtocol
|
|||||||
if ($host = data_get($protocol_settings, 'network_settings.host'))
|
if ($host = data_get($protocol_settings, 'network_settings.host'))
|
||||||
$array['ws-opts']['headers'] = ['Host' => $host];
|
$array['ws-opts']['headers'] = ['Host' => $host];
|
||||||
break;
|
break;
|
||||||
|
case 'xhttp':
|
||||||
|
$array['network'] = 'xhttp';
|
||||||
|
$xhttpOpts = [];
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$xhttpOpts['path'] = $path;
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host'))
|
||||||
|
$xhttpOpts['host'] = $host;
|
||||||
|
if ($mode = data_get($protocol_settings, 'network_settings.mode'))
|
||||||
|
$xhttpOpts['mode'] = $mode;
|
||||||
|
if (!empty($xhttpOpts))
|
||||||
|
$array['xhttp-opts'] = $xhttpOpts;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -602,8 +635,8 @@ class ClashMeta extends AbstractProtocol
|
|||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
default: // Standard TLS
|
default: // Standard TLS
|
||||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', data_get($protocol_settings, 'allow_insecure', false));
|
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
|
||||||
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name', data_get($protocol_settings, 'server_name'))) {
|
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||||
$array['sni'] = $serverName;
|
$array['sni'] = $serverName;
|
||||||
}
|
}
|
||||||
self::appendEch($array, data_get($protocol_settings, 'tls_settings.ech'));
|
self::appendEch($array, data_get($protocol_settings, 'tls_settings.ech'));
|
||||||
|
|||||||
@@ -135,6 +135,17 @@ class General extends AbstractProtocol
|
|||||||
$config['path'] = $path;
|
$config['path'] = $path;
|
||||||
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
||||||
break;
|
break;
|
||||||
|
case 'xhttp':
|
||||||
|
$config['net'] = 'xhttp';
|
||||||
|
$config['type'] = 'xhttp';
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$config['path'] = $path;
|
||||||
|
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
||||||
|
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto'))
|
||||||
|
$config['mode'] = $mode;
|
||||||
|
if ($extra = data_get($protocol_settings, 'network_settings.extra'))
|
||||||
|
$config['extra'] = is_array($extra) && !empty($extra) ? json_encode($extra) : null;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -216,10 +227,13 @@ class General extends AbstractProtocol
|
|||||||
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
||||||
break;
|
break;
|
||||||
case 'xhttp':
|
case 'xhttp':
|
||||||
$config['path'] = data_get($protocol_settings, 'network_settings.path');
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$config['path'] = $path;
|
||||||
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
||||||
$config['mode'] = data_get($protocol_settings, 'network_settings.mode', 'auto');
|
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto'))
|
||||||
$config['extra'] = json_encode(data_get($protocol_settings, 'network_settings.extra'));
|
$config['mode'] = $mode;
|
||||||
|
if ($extra = data_get($protocol_settings, 'network_settings.extra'))
|
||||||
|
$config['extra'] = is_array($extra) && !empty($extra) ? json_encode($extra) : null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,8 +262,8 @@ class General extends AbstractProtocol
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default: // Standard TLS
|
default: // Standard TLS
|
||||||
$array['allowInsecure'] = data_get($protocol_settings, 'allow_insecure', false);
|
$array['allowInsecure'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
|
||||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||||
$array['peer'] = $serverName;
|
$array['peer'] = $serverName;
|
||||||
$array['sni'] = $serverName;
|
$array['sni'] = $serverName;
|
||||||
}
|
}
|
||||||
@@ -286,6 +300,16 @@ class General extends AbstractProtocol
|
|||||||
$array['path'] = $path;
|
$array['path'] = $path;
|
||||||
$array['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
$array['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
||||||
break;
|
break;
|
||||||
|
case 'xhttp':
|
||||||
|
$array['type'] = 'xhttp';
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$array['path'] = $path;
|
||||||
|
$array['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
|
||||||
|
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto'))
|
||||||
|
$array['mode'] = $mode;
|
||||||
|
if ($extra = data_get($protocol_settings, 'network_settings.extra'))
|
||||||
|
$array['extra'] = is_array($extra) && !empty($extra) ? json_encode($extra) : null;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-2
@@ -205,10 +205,10 @@ class Loon extends AbstractProtocol
|
|||||||
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'reality_settings.allow_insecure', false) ? 'true' : 'false');
|
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'reality_settings.allow_insecure', false) ? 'true' : 'false');
|
||||||
break;
|
break;
|
||||||
default: // Standard TLS
|
default: // Standard TLS
|
||||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||||
$config[] = "tls-name={$serverName}";
|
$config[] = "tls-name={$serverName}";
|
||||||
}
|
}
|
||||||
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'allow_insecure') ? 'true' : 'false');
|
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'tls_settings.allow_insecure', false) ? 'true' : 'false');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +225,20 @@ class Loon extends AbstractProtocol
|
|||||||
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
|
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
|
||||||
$config[] = "grpc-service-name={$serviceName}";
|
$config[] = "grpc-service-name={$serviceName}";
|
||||||
break;
|
break;
|
||||||
|
case 'h2':
|
||||||
|
$config[] = 'transport=h2';
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$config[] = "path={$path}";
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host'))
|
||||||
|
$config[] = "host=" . (is_array($host) ? $host[0] : $host);
|
||||||
|
break;
|
||||||
|
case 'httpupgrade':
|
||||||
|
$config[] = 'transport=httpupgrade';
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$config[] = "path={$path}";
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host']))
|
||||||
|
$config[] = "host={$host}";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$config = array_filter($config);
|
$config = array_filter($config);
|
||||||
@@ -295,6 +309,24 @@ class Loon extends AbstractProtocol
|
|||||||
$config[] = "grpc-service-name={$serviceName}";
|
$config[] = "grpc-service-name={$serviceName}";
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'h2':
|
||||||
|
$config[] = "transport=h2";
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
||||||
|
$config[] = "path={$path}";
|
||||||
|
}
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host')) {
|
||||||
|
$config[] = "host=" . (is_array($host) ? $host[0] : $host);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'httpupgrade':
|
||||||
|
$config[] = "transport=httpupgrade";
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
||||||
|
$config[] = "path={$path}";
|
||||||
|
}
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) {
|
||||||
|
$config[] = "host={$host}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
$config[] = "transport=tcp";
|
$config[] = "transport=tcp";
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ class QuantumultX extends AbstractProtocol
|
|||||||
];
|
];
|
||||||
|
|
||||||
$tlsData = [
|
$tlsData = [
|
||||||
'allow_insecure' => data_get($protocol_settings, 'allow_insecure', false),
|
'allow_insecure' => data_get($protocol_settings, 'tls_settings.allow_insecure', false),
|
||||||
'server_name' => data_get($protocol_settings, 'server_name'),
|
'server_name' => data_get($protocol_settings, 'tls_settings.server_name'),
|
||||||
];
|
];
|
||||||
self::applyTransportSettings($config, $protocol_settings, true, $tlsData);
|
self::applyTransportSettings($config, $protocol_settings, true, $tlsData);
|
||||||
self::applyCommonSettings($config, $server);
|
self::applyCommonSettings($config, $server);
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class Shadowrocket extends AbstractProtocol
|
|||||||
protected $protocolRequirements = [
|
protected $protocolRequirements = [
|
||||||
'shadowrocket.hysteria.protocol_settings.version' => [2 => '1993'],
|
'shadowrocket.hysteria.protocol_settings.version' => [2 => '1993'],
|
||||||
'shadowrocket.anytls.base_version' => '2592',
|
'shadowrocket.anytls.base_version' => '2592',
|
||||||
|
'shadowrocket.trojan.protocol_settings.network' => [
|
||||||
|
'whitelist' => ['tcp', 'ws', 'grpc', 'h2', 'httpupgrade'],
|
||||||
|
'strict' => true,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function handle()
|
public function handle()
|
||||||
@@ -137,7 +141,7 @@ class Shadowrocket extends AbstractProtocol
|
|||||||
$config['obfsParam'] = $host;
|
$config['obfsParam'] = $host;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'h2':
|
case 'h2':
|
||||||
$config['obfs'] = "h2";
|
$config['obfs'] = "h2";
|
||||||
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
||||||
$config['path'] = $path;
|
$config['path'] = $path;
|
||||||
@@ -147,6 +151,18 @@ class Shadowrocket extends AbstractProtocol
|
|||||||
$config['peer'] = $host [0] ?? $server['host'];
|
$config['peer'] = $host [0] ?? $server['host'];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'xhttp':
|
||||||
|
$config['obfs'] = "xhttp";
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
||||||
|
$config['path'] = $path;
|
||||||
|
}
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) {
|
||||||
|
$config['obfsParam'] = $host;
|
||||||
|
}
|
||||||
|
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto')) {
|
||||||
|
$config['mode'] = $mode;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
$query = http_build_query($config, '', '&', PHP_QUERY_RFC3986);
|
$query = http_build_query($config, '', '&', PHP_QUERY_RFC3986);
|
||||||
$uri = "vmess://{$userinfo}?{$query}";
|
$uri = "vmess://{$userinfo}?{$query}";
|
||||||
@@ -161,7 +177,6 @@ class Shadowrocket extends AbstractProtocol
|
|||||||
$config = [
|
$config = [
|
||||||
'tfo' => 1,
|
'tfo' => 1,
|
||||||
'remark' => $server['name'],
|
'remark' => $server['name'],
|
||||||
'alterId' => 0
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 判断是否开启xtls
|
// 判断是否开启xtls
|
||||||
@@ -282,8 +297,8 @@ class Shadowrocket extends AbstractProtocol
|
|||||||
$params['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
|
$params['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
|
||||||
break;
|
break;
|
||||||
default: // Standard TLS
|
default: // Standard TLS
|
||||||
$params['allowInsecure'] = data_get($protocol_settings, 'allow_insecure');
|
$params['allowInsecure'] = (int) data_get($protocol_settings, 'tls_settings.allow_insecure');
|
||||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||||
$params['peer'] = $serverName;
|
$params['peer'] = $serverName;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -299,6 +314,29 @@ class Shadowrocket extends AbstractProtocol
|
|||||||
$path = data_get($protocol_settings, 'network_settings.path');
|
$path = data_get($protocol_settings, 'network_settings.path');
|
||||||
$params['plugin'] = "obfs-local;obfs=websocket;obfs-host={$host};obfs-uri={$path}";
|
$params['plugin'] = "obfs-local;obfs=websocket;obfs-host={$host};obfs-uri={$path}";
|
||||||
break;
|
break;
|
||||||
|
case 'h2':
|
||||||
|
$params['obfs'] = 'h2';
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$params['path'] = $path;
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host']))
|
||||||
|
$params['obfsParam'] = is_array($host) ? $host[0] : $host;
|
||||||
|
break;
|
||||||
|
case 'httpupgrade':
|
||||||
|
$params['obfs'] = 'httpupgrade';
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$params['path'] = $path;
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host']))
|
||||||
|
$params['obfsParam'] = $host;
|
||||||
|
break;
|
||||||
|
case 'xhttp':
|
||||||
|
$params['obfs'] = 'xhttp';
|
||||||
|
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||||
|
$params['path'] = $path;
|
||||||
|
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host']))
|
||||||
|
$params['obfsParam'] = $host;
|
||||||
|
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto'))
|
||||||
|
$params['mode'] = $mode;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
$query = http_build_query($params);
|
$query = http_build_query($params);
|
||||||
$addr = Helper::wrapIPv6($server['host']);
|
$addr = Helper::wrapIPv6($server['host']);
|
||||||
|
|||||||
@@ -40,16 +40,25 @@ class SingBox extends AbstractProtocol
|
|||||||
],
|
],
|
||||||
'protocol_settings.tls_settings.ech.enabled' => [
|
'protocol_settings.tls_settings.ech.enabled' => [
|
||||||
1 => '1.5.0'
|
1 => '1.5.0'
|
||||||
|
],
|
||||||
|
'protocol_settings.network' => [
|
||||||
|
'xhttp' => '9999.0.0'
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'vmess' => [
|
'vmess' => [
|
||||||
'protocol_settings.tls_settings.ech.enabled' => [
|
'protocol_settings.tls_settings.ech.enabled' => [
|
||||||
1 => '1.5.0'
|
1 => '1.5.0'
|
||||||
|
],
|
||||||
|
'protocol_settings.network' => [
|
||||||
|
'xhttp' => '9999.0.0'
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'trojan' => [
|
'trojan' => [
|
||||||
'protocol_settings.tls_settings.ech.enabled' => [
|
'protocol_settings.tls_settings.ech.enabled' => [
|
||||||
1 => '1.5.0'
|
1 => '1.5.0'
|
||||||
|
],
|
||||||
|
'protocol_settings.network' => [
|
||||||
|
'xhttp' => '9999.0.0'
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'hysteria' => [
|
'hysteria' => [
|
||||||
@@ -537,9 +546,9 @@ class SingBox extends AbstractProtocol
|
|||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
default: // Standard TLS
|
default: // Standard TLS
|
||||||
$tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', data_get($protocol_settings, 'allow_insecure', false));
|
$tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
|
||||||
$this->appendEch($tlsConfig, data_get($protocol_settings, 'tls_settings.ech'));
|
$this->appendEch($tlsConfig, data_get($protocol_settings, 'tls_settings.ech'));
|
||||||
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name', data_get($protocol_settings, 'server_name'))) {
|
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||||
$tlsConfig['server_name'] = $serverName;
|
$tlsConfig['server_name'] = $serverName;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -397,10 +397,10 @@ class Stash extends AbstractProtocol
|
|||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
default: // Standard TLS
|
default: // Standard TLS
|
||||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||||
$array['sni'] = $serverName;
|
$array['sni'] = $serverName;
|
||||||
}
|
}
|
||||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure', false);
|
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,12 +186,12 @@ class Surfboard extends AbstractProtocol
|
|||||||
"{$server['host']}",
|
"{$server['host']}",
|
||||||
"{$server['port']}",
|
"{$server['port']}",
|
||||||
"password={$password}",
|
"password={$password}",
|
||||||
data_get($protocol_settings, 'server_name') ? "sni=" . data_get($protocol_settings, 'server_name') : "",
|
data_get($protocol_settings, 'tls_settings.server_name') ? "sni=" . data_get($protocol_settings, 'tls_settings.server_name') : "",
|
||||||
'tfo=true',
|
'tfo=true',
|
||||||
'udp-relay=true'
|
'udp-relay=true'
|
||||||
];
|
];
|
||||||
if (data_get($protocol_settings, 'allow_insecure')) {
|
if (data_get($protocol_settings, 'tls_settings.allow_insecure', false)) {
|
||||||
array_push($config, !!data_get($protocol_settings, 'allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false');
|
$config[] = 'skip-cert-verify=true';
|
||||||
}
|
}
|
||||||
$config = array_filter($config);
|
$config = array_filter($config);
|
||||||
$uri = implode(',', $config);
|
$uri = implode(',', $config);
|
||||||
|
|||||||
@@ -195,12 +195,12 @@ class Surge extends AbstractProtocol
|
|||||||
"{$server['host']}",
|
"{$server['host']}",
|
||||||
"{$server['port']}",
|
"{$server['port']}",
|
||||||
"password={$password}",
|
"password={$password}",
|
||||||
data_get($protocol_settings, 'server_name') ? "sni=" . data_get($protocol_settings, 'server_name') : "",
|
data_get($protocol_settings, 'tls_settings.server_name') ? "sni=" . data_get($protocol_settings, 'tls_settings.server_name') : "",
|
||||||
'tfo=true',
|
'tfo=true',
|
||||||
'udp-relay=true'
|
'udp-relay=true'
|
||||||
];
|
];
|
||||||
if (!empty($protocol_settings['allow_insecure'])) {
|
if (data_get($protocol_settings, 'tls_settings.allow_insecure', false)) {
|
||||||
array_push($config, !!data_get($protocol_settings, 'allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false');
|
$config[] = 'skip-cert-verify=true';
|
||||||
}
|
}
|
||||||
$config = array_filter($config);
|
$config = array_filter($config);
|
||||||
$uri = implode(',', $config);
|
$uri = implode(',', $config);
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ class PluginServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
if (!file_exists(base_path('plugins'))) {
|
foreach (['plugins', 'plugins-core'] as $dir) {
|
||||||
mkdir(base_path('plugins'), 0755, true);
|
$path = base_path($dir);
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
mkdir($path, 0755, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ class MailLinkService
|
|||||||
'subject' => __('Login to :name', [
|
'subject' => __('Login to :name', [
|
||||||
'name' => admin_setting('app_name', 'Notification Service')
|
'name' => admin_setting('app_name', 'Notification Service')
|
||||||
]),
|
]),
|
||||||
'template_name' => 'login',
|
'template_name' => 'mailLogin',
|
||||||
'template_value' => [
|
'template_value' => [
|
||||||
'name' => admin_setting('app_name', 'Notification Service'),
|
'name' => admin_setting('app_name', 'Notification Service'),
|
||||||
'link' => $link,
|
'link' => $link,
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class DeviceStateService
|
|||||||
|
|
||||||
$this->removeNodeDevices($nodeId, $userId);
|
$this->removeNodeDevices($nodeId, $userId);
|
||||||
|
|
||||||
|
// Normalize: strip port suffix and deduplicate
|
||||||
|
$ips = array_values(array_unique(array_map([self::class, 'normalizeIP'], $ips)));
|
||||||
|
|
||||||
if (!empty($ips)) {
|
if (!empty($ips)) {
|
||||||
$fields = [];
|
$fields = [];
|
||||||
foreach ($ips as $ip) {
|
foreach ($ips as $ip) {
|
||||||
@@ -98,6 +101,7 @@ class DeviceStateService
|
|||||||
Redis::hdel($key, $field);
|
Redis::hdel($key, $field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$this->notifyUpdate($userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_keys($oldDevices);
|
return array_keys($oldDevices);
|
||||||
@@ -166,6 +170,22 @@ class DeviceStateService
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip port from IP address: "1.2.3.4:12345" → "1.2.3.4", "[::1]:443" → "::1"
|
||||||
|
*/
|
||||||
|
private static function normalizeIP(string $ip): string
|
||||||
|
{
|
||||||
|
// [IPv6]:port
|
||||||
|
if (preg_match('/^\[(.+)\]:\d+$/', $ip, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
// IPv4:port
|
||||||
|
if (preg_match('/^(\d+\.\d+\.\d+\.\d+):\d+$/', $ip, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* notify update (throttle control)
|
* notify update (throttle control)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Jobs\SendEmailJob;
|
use App\Jobs\SendEmailJob;
|
||||||
use App\Models\MailLog;
|
use App\Models\MailLog;
|
||||||
|
use App\Models\MailTemplate;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Utils\CacheKey;
|
use App\Utils\CacheKey;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Illuminate\Support\Str;
|
|||||||
class PluginManager
|
class PluginManager
|
||||||
{
|
{
|
||||||
protected string $pluginPath;
|
protected string $pluginPath;
|
||||||
|
protected string $corePluginPath;
|
||||||
protected array $loadedPlugins = [];
|
protected array $loadedPlugins = [];
|
||||||
protected bool $pluginsInitialized = false;
|
protected bool $pluginsInitialized = false;
|
||||||
protected array $configTypesCache = [];
|
protected array $configTypesCache = [];
|
||||||
@@ -21,6 +22,7 @@ class PluginManager
|
|||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->pluginPath = base_path('plugins');
|
$this->pluginPath = base_path('plugins');
|
||||||
|
$this->corePluginPath = base_path('plugins-core');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,14 +33,42 @@ class PluginManager
|
|||||||
return 'Plugin\\' . Str::studly($pluginCode);
|
return 'Plugin\\' . Str::studly($pluginCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function resolvePluginPath(string $pluginCode): ?string
|
||||||
* 获取插件的基础路径
|
{
|
||||||
*/
|
$dirName = Str::studly($pluginCode);
|
||||||
|
$corePath = $this->corePluginPath . '/' . $dirName;
|
||||||
|
if (File::isDirectory($corePath)) {
|
||||||
|
return $corePath;
|
||||||
|
}
|
||||||
|
$userPath = $this->pluginPath . '/' . $dirName;
|
||||||
|
if (File::isDirectory($userPath)) {
|
||||||
|
return $userPath;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public function getPluginPath(string $pluginCode): string
|
public function getPluginPath(string $pluginCode): string
|
||||||
|
{
|
||||||
|
return $this->resolvePluginPath($pluginCode)
|
||||||
|
?? $this->pluginPath . '/' . Str::studly($pluginCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserPluginPath(string $pluginCode): string
|
||||||
{
|
{
|
||||||
return $this->pluginPath . '/' . Str::studly($pluginCode);
|
return $this->pluginPath . '/' . Str::studly($pluginCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isCorePlugin(string $pluginCode): bool
|
||||||
|
{
|
||||||
|
$dirName = Str::studly($pluginCode);
|
||||||
|
return File::isDirectory($this->corePluginPath . '/' . $dirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPluginPaths(): array
|
||||||
|
{
|
||||||
|
return [$this->corePluginPath, $this->pluginPath];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载插件类
|
* 加载插件类
|
||||||
*/
|
*/
|
||||||
@@ -399,17 +429,19 @@ class PluginManager
|
|||||||
*/
|
*/
|
||||||
public function delete(string $pluginCode): bool
|
public function delete(string $pluginCode): bool
|
||||||
{
|
{
|
||||||
// 先卸载插件
|
|
||||||
if (Plugin::where('code', $pluginCode)->exists()) {
|
if (Plugin::where('code', $pluginCode)->exists()) {
|
||||||
$this->uninstall($pluginCode);
|
$this->uninstall($pluginCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
$pluginPath = $this->getPluginPath($pluginCode);
|
if ($this->isCorePlugin($pluginCode)) {
|
||||||
|
throw new \Exception('核心插件不允许删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pluginPath = $this->getUserPluginPath($pluginCode);
|
||||||
if (!File::exists($pluginPath)) {
|
if (!File::exists($pluginPath)) {
|
||||||
throw new \Exception('插件不存在');
|
throw new \Exception('插件不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除插件目录
|
|
||||||
File::deleteDirectory($pluginPath);
|
File::deleteDirectory($pluginPath);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -527,7 +559,7 @@ class PluginManager
|
|||||||
throw new \Exception('插件配置文件格式错误');
|
throw new \Exception('插件配置文件格式错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetPath = $this->pluginPath . '/' . Str::studly($config['code']);
|
$targetPath = $this->getUserPluginPath($config['code']);
|
||||||
if (File::exists($targetPath)) {
|
if (File::exists($targetPath)) {
|
||||||
$installedConfigPath = $targetPath . '/config.json';
|
$installedConfigPath = $targetPath . '/config.json';
|
||||||
if (!File::exists($installedConfigPath)) {
|
if (!File::exists($installedConfigPath)) {
|
||||||
@@ -678,12 +710,27 @@ class PluginManager
|
|||||||
*/
|
*/
|
||||||
public static function installDefaultPlugins(): void
|
public static function installDefaultPlugins(): void
|
||||||
{
|
{
|
||||||
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
|
$pluginManager = app(self::class);
|
||||||
if (!Plugin::where('code', $pluginCode)->exists()) {
|
$coreDir = base_path('plugins-core');
|
||||||
$pluginManager = app(self::class);
|
|
||||||
$pluginManager->install($pluginCode);
|
if (!File::isDirectory($coreDir)) {
|
||||||
$pluginManager->enable($pluginCode);
|
return;
|
||||||
Log::info("Installed and enabled default plugin: {$pluginCode}");
|
}
|
||||||
|
|
||||||
|
foreach (File::directories($coreDir) as $directory) {
|
||||||
|
$configFile = $directory . '/config.json';
|
||||||
|
if (!File::exists($configFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$config = json_decode(File::get($configFile), true);
|
||||||
|
$code = $config['code'] ?? null;
|
||||||
|
if (!$code) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Plugin::where('code', $code)->exists()) {
|
||||||
|
$pluginManager->install($code);
|
||||||
|
$pluginManager->enable($code);
|
||||||
|
Log::info("Installed and enabled core plugin: {$code}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,15 +284,12 @@ class ServerService
|
|||||||
'trojan' => [
|
'trojan' => [
|
||||||
...$baseConfig,
|
...$baseConfig,
|
||||||
'host' => $host,
|
'host' => $host,
|
||||||
'server_name' => data_get($protocolSettings, 'tls_settings.server_name') ?? $protocolSettings['server_name'],
|
'server_name' => data_get($protocolSettings, 'tls_settings.server_name'),
|
||||||
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||||
'tls' => (int) $protocolSettings['tls'],
|
'tls' => (int) $protocolSettings['tls'],
|
||||||
'tls_settings' => match ((int) $protocolSettings['tls']) {
|
'tls_settings' => match ((int) $protocolSettings['tls']) {
|
||||||
2 => $protocolSettings['reality_settings'],
|
2 => $protocolSettings['reality_settings'],
|
||||||
default => array_merge($protocolSettings['tls_settings'] ?? [], [
|
default => $protocolSettings['tls_settings'],
|
||||||
'server_name' => data_get($protocolSettings, 'tls_settings.server_name') ?? $protocolSettings['server_name'],
|
|
||||||
'allow_insecure' => data_get($protocolSettings, 'tls_settings.allow_insecure', $protocolSettings['allow_insecure']),
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'vless' => [
|
'vless' => [
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ class TicketService
|
|||||||
'ticket_id' => $ticket->id,
|
'ticket_id' => $ticket->id,
|
||||||
'message' => $message
|
'message' => $message
|
||||||
]);
|
]);
|
||||||
if ($userId !== $ticket->user_id) {
|
$isAdmin = $userId !== $ticket->user_id;
|
||||||
$ticket->reply_status = Ticket::STATUS_OPENING;
|
$ticket->reply_status = $isAdmin
|
||||||
} else {
|
? Ticket::REPLY_STATUS_REPLIED
|
||||||
$ticket->reply_status = Ticket::STATUS_CLOSED;
|
: Ticket::REPLY_STATUS_WAITING;
|
||||||
}
|
$ticket->last_reply_user_id = $userId;
|
||||||
if (!$ticketMessage || !$ticket->save()) {
|
if (!$ticketMessage || !$ticket->save()) {
|
||||||
throw new \Exception();
|
throw new \Exception();
|
||||||
}
|
}
|
||||||
@@ -40,33 +40,15 @@ class TicketService
|
|||||||
|
|
||||||
public function replyByAdmin($ticketId, $message, $userId): void
|
public function replyByAdmin($ticketId, $message, $userId): void
|
||||||
{
|
{
|
||||||
$ticket = Ticket::where('id', $ticketId)
|
$ticket = Ticket::where('id', $ticketId)->first();
|
||||||
->first();
|
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
throw new ApiException('工单不存在');
|
throw new ApiException('工单不存在');
|
||||||
}
|
}
|
||||||
$ticket->status = Ticket::STATUS_OPENING;
|
$ticketMessage = $this->reply($ticket, $message, $userId);
|
||||||
try {
|
if (!$ticketMessage) {
|
||||||
DB::beginTransaction();
|
throw new ApiException('工单回复失败');
|
||||||
$ticketMessage = TicketMessage::create([
|
|
||||||
'user_id' => $userId,
|
|
||||||
'ticket_id' => $ticket->id,
|
|
||||||
'message' => $message
|
|
||||||
]);
|
|
||||||
if ($userId !== $ticket->user_id) {
|
|
||||||
$ticket->reply_status = Ticket::STATUS_OPENING;
|
|
||||||
} else {
|
|
||||||
$ticket->reply_status = Ticket::STATUS_CLOSED;
|
|
||||||
}
|
|
||||||
if (!$ticketMessage || !$ticket->save()) {
|
|
||||||
throw new ApiException('工单回复失败');
|
|
||||||
}
|
|
||||||
DB::commit();
|
|
||||||
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
DB::rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
}
|
||||||
|
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
|
||||||
$this->sendEmailNotify($ticket, $ticketMessage);
|
$this->sendEmailNotify($ticket, $ticketMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +63,9 @@ class TicketService
|
|||||||
$ticket = Ticket::create([
|
$ticket = Ticket::create([
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'level' => $level
|
'level' => $level,
|
||||||
|
'reply_status' => Ticket::REPLY_STATUS_WAITING,
|
||||||
|
'last_reply_user_id' => $userId,
|
||||||
]);
|
]);
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
throw new ApiException('工单创建失败');
|
throw new ApiException('工单创建失败');
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ abstract class AbstractProtocol
|
|||||||
if (is_array($filterRule) && isset($filterRule['whitelist'])) {
|
if (is_array($filterRule) && isset($filterRule['whitelist'])) {
|
||||||
$allowedValues = $filterRule['whitelist'];
|
$allowedValues = $filterRule['whitelist'];
|
||||||
$strict = $filterRule['strict'] ?? false;
|
$strict = $filterRule['strict'] ?? false;
|
||||||
|
// Normalize flat array ['tcp', 'ws'] to ['tcp' => '0.0.0', 'ws' => '0.0.0']
|
||||||
|
if (!empty($allowedValues) && is_int(array_key_first($allowedValues))) {
|
||||||
|
$allowedValues = array_fill_keys($allowedValues, '0.0.0');
|
||||||
|
}
|
||||||
if ($strict) {
|
if ($strict) {
|
||||||
if ($actualValue === null) {
|
if ($actualValue === null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class NodeWorker
|
|||||||
private const AUTH_TIMEOUT = 10;
|
private const AUTH_TIMEOUT = 10;
|
||||||
private const PING_INTERVAL = 55;
|
private const PING_INTERVAL = 55;
|
||||||
|
|
||||||
|
public const HEARTBEAT_CACHE_KEY = 'ws_server:heartbeat';
|
||||||
|
private const HEARTBEAT_INTERVAL = 10;
|
||||||
|
private const HEARTBEAT_TTL = 30;
|
||||||
|
|
||||||
private Worker $worker;
|
private Worker $worker;
|
||||||
|
|
||||||
private array $handlers = [
|
private array $handlers = [
|
||||||
@@ -70,6 +74,11 @@ class NodeWorker
|
|||||||
|
|
||||||
private function setupTimers(): void
|
private function setupTimers(): void
|
||||||
{
|
{
|
||||||
|
Cache::put(self::HEARTBEAT_CACHE_KEY, time(), self::HEARTBEAT_TTL);
|
||||||
|
Timer::add(self::HEARTBEAT_INTERVAL, function () {
|
||||||
|
Cache::put(self::HEARTBEAT_CACHE_KEY, time(), self::HEARTBEAT_TTL);
|
||||||
|
});
|
||||||
|
|
||||||
Timer::add(self::PING_INTERVAL, function () {
|
Timer::add(self::PING_INTERVAL, function () {
|
||||||
$seen = [];
|
$seen = [];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Deployment for 1Panel users.
|
||||||
|
#
|
||||||
|
# 1Panel runs MySQL/Redis as separate containers on a Docker network named
|
||||||
|
# `1panel-network`. To let Xboard reach them by their container hostname
|
||||||
|
# (e.g. `1Panel-mysql-xxxx`), this compose file joins that external network
|
||||||
|
# in addition to publishing port 7001 for 1Panel's reverse proxy / website.
|
||||||
|
#
|
||||||
|
# During `php artisan xboard:install`, set:
|
||||||
|
# - Database Host: the container name shown in 1Panel under
|
||||||
|
# Database -> Connection Info -> Host
|
||||||
|
# - Redis: choose the built-in Redis (already provided by this image)
|
||||||
|
services:
|
||||||
|
xboard:
|
||||||
|
image: ghcr.io/cedar2025/xboard:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7001:7001"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- 1panel-network
|
||||||
|
volumes:
|
||||||
|
- ./.env:/www/.env
|
||||||
|
- ./.docker/.data/:/www/.docker/.data
|
||||||
|
- ./storage/logs:/www/storage/logs
|
||||||
|
- ./storage/theme:/www/storage/theme
|
||||||
|
- ./plugins:/www/plugins
|
||||||
|
- redis-data:/data
|
||||||
|
environment:
|
||||||
|
- RESOURCE_PROFILE=balanced # minimal | balanced | performance | auto
|
||||||
|
- ENABLE_HORIZON=true
|
||||||
|
- docker=true
|
||||||
|
|
||||||
|
networks:
|
||||||
|
1panel-network:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
xboard:
|
||||||
|
image: ghcr.io/cedar2025/xboard:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./.env:/www/.env
|
||||||
|
- ./.docker/.data/:/www/.docker/.data
|
||||||
|
- ./storage/logs:/www/storage/logs
|
||||||
|
- ./storage/theme:/www/storage/theme
|
||||||
|
- ./plugins:/www/plugins
|
||||||
|
- redis-data:/data
|
||||||
|
environment:
|
||||||
|
- RESOURCE_PROFILE=balanced # minimal | balanced | performance | auto
|
||||||
|
- ENABLE_HORIZON=true
|
||||||
|
- docker=true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
# Default deployment: bridge network with port 7001 published to the host.
|
||||||
|
# Suitable for: bare docker-compose, aaPanel + Docker manager, custom reverse
|
||||||
|
# proxies (nginx, Caddy on host, Cloudflare Tunnel, etc.) that talk to
|
||||||
|
# 127.0.0.1:7001 on the host.
|
||||||
|
#
|
||||||
|
# For 1Panel users: use compose.1panel.sample.yaml so the container can reach
|
||||||
|
# the 1Panel-managed MySQL/Redis on the 1panel-network.
|
||||||
|
#
|
||||||
|
# For aaPanel native (openresty on host) users that prefer host networking:
|
||||||
|
# use compose.host.sample.yaml.
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -50,6 +60,8 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777 --save 900 1 --save 300 10 --save 60 10000
|
command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777 --save 900 1 --save 300 10 --save 60 10000
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7001:7001"
|
||||||
volumes:
|
volumes:
|
||||||
- ./.docker/.data/redis:/data
|
- ./.docker/.data/redis:/data
|
||||||
sysctls:
|
sysctls:
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Split deployment for K8s users or operators who want to scale, restart and
|
||||||
|
# limit each process independently. The single image is reused across services
|
||||||
|
# by overriding the command and disabling the supervisor programs that are not
|
||||||
|
# relevant to that role.
|
||||||
|
#
|
||||||
|
# Topology:
|
||||||
|
# caddy ──┬─→ web (HTTP) :7001 → octane :7001
|
||||||
|
# └─→ ws-server (WebSocket) /api/v2/server/ws → :8076
|
||||||
|
# horizon (queue worker, no public port)
|
||||||
|
# redis (state)
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7001:7001"
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
- ws-server
|
||||||
|
volumes:
|
||||||
|
- ./.docker/caddy/Caddyfile.split:/etc/caddy/Caddyfile:ro
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ghcr.io/cedar2025/xboard:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
volumes: &shared-volumes
|
||||||
|
- ./.env:/www/.env
|
||||||
|
- ./.docker/.data/:/www/.docker/.data
|
||||||
|
- ./storage/logs:/www/storage/logs
|
||||||
|
- ./storage/theme:/www/storage/theme
|
||||||
|
- ./plugins:/www/plugins
|
||||||
|
environment:
|
||||||
|
docker: "true"
|
||||||
|
ENABLE_CADDY: "false"
|
||||||
|
ENABLE_HORIZON: "false"
|
||||||
|
ENABLE_WS_SERVER: "false"
|
||||||
|
|
||||||
|
horizon:
|
||||||
|
image: ghcr.io/cedar2025/xboard:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
volumes: *shared-volumes
|
||||||
|
environment:
|
||||||
|
docker: "true"
|
||||||
|
ENABLE_CADDY: "false"
|
||||||
|
ENABLE_WEB: "false"
|
||||||
|
ENABLE_WS_SERVER: "false"
|
||||||
|
|
||||||
|
ws-server:
|
||||||
|
image: ghcr.io/cedar2025/xboard:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
volumes: *shared-volumes
|
||||||
|
environment:
|
||||||
|
docker: "true"
|
||||||
|
ENABLE_CADDY: "false"
|
||||||
|
ENABLE_WEB: "false"
|
||||||
|
ENABLE_HORIZON: "false"
|
||||||
|
WS_HOST: "0.0.0.0"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777 --save 900 1 --save 300 10 --save 60 10000
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
Generated
+12076
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -176,7 +176,10 @@ return [
|
|||||||
'balance' => 'auto',
|
'balance' => 'auto',
|
||||||
'autoScalingStrategy' => 'time',
|
'autoScalingStrategy' => 'time',
|
||||||
'minProcesses' => 1,
|
'minProcesses' => 1,
|
||||||
'maxProcesses' => 8,
|
'maxProcesses' => (int) env('HORIZON_DATA_PIPELINE_MAX', 8),
|
||||||
|
'memory' => (int) env('HORIZON_WORKER_MEMORY_MB', 128),
|
||||||
|
'maxTime' => (int) env('HORIZON_WORKER_MAX_TIME', 3600),
|
||||||
|
'maxJobs' => (int) env('HORIZON_WORKER_MAX_JOBS', 1000),
|
||||||
'balanceCooldown' => 1,
|
'balanceCooldown' => 1,
|
||||||
'tries' => 3,
|
'tries' => 3,
|
||||||
'timeout' => 30,
|
'timeout' => 30,
|
||||||
@@ -186,7 +189,10 @@ return [
|
|||||||
'queue' => ['default', 'order_handle'],
|
'queue' => ['default', 'order_handle'],
|
||||||
'balance' => 'simple',
|
'balance' => 'simple',
|
||||||
'minProcesses' => 1,
|
'minProcesses' => 1,
|
||||||
'maxProcesses' => 3,
|
'maxProcesses' => (int) env('HORIZON_BUSINESS_MAX', 3),
|
||||||
|
'memory' => (int) env('HORIZON_WORKER_MEMORY_MB', 128),
|
||||||
|
'maxTime' => (int) env('HORIZON_WORKER_MAX_TIME', 3600),
|
||||||
|
'maxJobs' => (int) env('HORIZON_WORKER_MAX_JOBS', 1000),
|
||||||
'tries' => 3,
|
'tries' => 3,
|
||||||
'timeout' => 30,
|
'timeout' => 30,
|
||||||
],
|
],
|
||||||
@@ -196,7 +202,10 @@ return [
|
|||||||
'balance' => 'auto',
|
'balance' => 'auto',
|
||||||
'autoScalingStrategy' => 'size',
|
'autoScalingStrategy' => 'size',
|
||||||
'minProcesses' => 1,
|
'minProcesses' => 1,
|
||||||
'maxProcesses' => 3,
|
'maxProcesses' => (int) env('HORIZON_NOTIFICATION_MAX', 3),
|
||||||
|
'memory' => (int) env('HORIZON_WORKER_MEMORY_MB', 128),
|
||||||
|
'maxTime' => (int) env('HORIZON_WORKER_MAX_TIME', 3600),
|
||||||
|
'maxJobs' => (int) env('HORIZON_WORKER_MAX_JOBS', 1000),
|
||||||
'tries' => 3,
|
'tries' => 3,
|
||||||
'timeout' => 60,
|
'timeout' => 60,
|
||||||
'backoff' => [3, 10, 30],
|
'backoff' => [3, 10, 30],
|
||||||
|
|||||||
+8
-10
@@ -102,8 +102,8 @@ return [
|
|||||||
|
|
||||||
OperationTerminated::class => [
|
OperationTerminated::class => [
|
||||||
FlushTemporaryContainerInstances::class,
|
FlushTemporaryContainerInstances::class,
|
||||||
DisconnectFromDatabases::class,
|
// DisconnectFromDatabases::class,
|
||||||
CollectGarbage::class,
|
// CollectGarbage::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
WorkerErrorOccurred::class => [
|
WorkerErrorOccurred::class => [
|
||||||
@@ -129,6 +129,7 @@ return [
|
|||||||
|
|
||||||
'warm' => [
|
'warm' => [
|
||||||
...Octane::defaultServicesToWarm(),
|
...Octane::defaultServicesToWarm(),
|
||||||
|
\App\Services\Plugin\PluginManager::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'flush' => [
|
'flush' => [
|
||||||
@@ -147,8 +148,8 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'cache' => [
|
'cache' => [
|
||||||
'rows' => 5000,
|
'rows' => (int) env('OCTANE_CACHE_ROWS', 1000),
|
||||||
'bytes' => 20000,
|
'bytes' => (int) env('OCTANE_CACHE_BYTES', 8192),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -163,10 +164,7 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'tables' => [
|
'tables' => [
|
||||||
'example:1000' => [
|
//
|
||||||
'name' => 'string:1000',
|
|
||||||
'votes' => 'int',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -203,7 +201,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'garbage' => 128,
|
'garbage' => (int) env('OCTANE_GARBAGE_MB', 128),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@@ -216,6 +214,6 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'max_execution_time' => 60,
|
'max_execution_time' => (int) env('OCTANE_MAX_EXECUTION_TIME', 60),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('v2_server_machine_load_history', function (Blueprint $table) {
|
||||||
|
$table->double('net_in_speed')->nullable()->after('disk_used');
|
||||||
|
$table->double('net_out_speed')->nullable()->after('net_in_speed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('v2_server_machine_load_history', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['net_in_speed', 'net_out_speed']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('v2_server', function (Blueprint $table) {
|
||||||
|
$table->boolean('enabled')->nullable()->default(true)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('v2_server', function (Blueprint $table) {
|
||||||
|
$table->boolean('enabled')->default(true)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot backfill of users.next_reset_at for legacy installs.
|
||||||
|
*
|
||||||
|
* Replaces the previous `reset:traffic --force` step in `xboard:update`,
|
||||||
|
* which had to run on every container start. Now it runs exactly once per
|
||||||
|
* database (Laravel migrations are tracked).
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasColumn('v2_user', 'next_reset_at')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Artisan::call('reset:traffic', ['--fix-null' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Backfill is non-destructive; nothing to roll back.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
// Backfill default utls for legacy vless reality nodes after the uTLS refactor.
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasTable('v2_server')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('v2_server')
|
||||||
|
->where('type', 'vless')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(200, function ($servers) {
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
$settings = json_decode($server->protocol_settings ?? '', true);
|
||||||
|
if (!is_array($settings) || (int) ($settings['tls'] ?? 0) != 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $settings['utls'] ?? null;
|
||||||
|
if (is_array($existing) && ($existing['enabled'] ?? false) === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings['utls'] = [
|
||||||
|
'enabled' => true,
|
||||||
|
'fingerprint' => is_array($existing) && !empty($existing['fingerprint'])
|
||||||
|
? $existing['fingerprint']
|
||||||
|
: 'chrome',
|
||||||
|
];
|
||||||
|
|
||||||
|
DB::table('v2_server')
|
||||||
|
->where('id', $server->id)
|
||||||
|
->update(['protocol_settings' => json_encode($settings)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('v2_mail_templates', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name', 64)->unique();
|
||||||
|
$table->string('subject', 255);
|
||||||
|
$table->longText('content');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('v2_mail_templates');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Add last_reply_user_id column if not exists
|
||||||
|
if (!Schema::hasColumn('v2_ticket', 'last_reply_user_id')) {
|
||||||
|
Schema::table('v2_ticket', function (Blueprint $table) {
|
||||||
|
$table->integer('last_reply_user_id')->nullable()->after('reply_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix reply_status semantics: swap 0 and 1
|
||||||
|
// Old: 0=admin replied, 1=user replied (inverted)
|
||||||
|
// New: 0=待回复(waiting), 1=已回复(replied) — matches frontend expectations
|
||||||
|
DB::table('v2_ticket')
|
||||||
|
->whereIn('reply_status', [0, 1])
|
||||||
|
->update([
|
||||||
|
'reply_status' => DB::raw("CASE WHEN reply_status = 0 THEN 1 WHEN reply_status = 1 THEN 0 END")
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fix default: new tickets should be "待回复" (0), not "已回复" (1)
|
||||||
|
Schema::table('v2_ticket', function (Blueprint $table) {
|
||||||
|
$table->integer('reply_status')->default(0)->comment('0:待回复 1:已回复')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Reverse the swap
|
||||||
|
DB::table('v2_ticket')
|
||||||
|
->whereIn('reply_status', [0, 1])
|
||||||
|
->update([
|
||||||
|
'reply_status' => DB::raw("CASE WHEN reply_status = 0 THEN 1 WHEN reply_status = 1 THEN 0 END")
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::table('v2_ticket', function (Blueprint $table) {
|
||||||
|
$table->integer('reply_status')->default(1)->comment('0:待回复 1:已回复')->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: last_reply_user_id column is intentionally kept to avoid dropping
|
||||||
|
// a column that may have existed before this migration.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('v2_server')
|
||||||
|
->where('type', 'trojan')
|
||||||
|
->chunkById(100, function ($servers) {
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
$settings = json_decode($server->protocol_settings, true);
|
||||||
|
if (!$settings) continue;
|
||||||
|
|
||||||
|
$rootSni = $settings['server_name'] ?? null;
|
||||||
|
$rootInsecure = $settings['allow_insecure'] ?? false;
|
||||||
|
$tlsSettings = $settings['tls_settings'] ?? null;
|
||||||
|
|
||||||
|
$needsUpdate = false;
|
||||||
|
|
||||||
|
if (!is_array($tlsSettings)) {
|
||||||
|
if ($rootSni !== null || $rootInsecure) {
|
||||||
|
$settings['tls_settings'] = [
|
||||||
|
'server_name' => $rootSni,
|
||||||
|
'allow_insecure' => (bool) $rootInsecure,
|
||||||
|
];
|
||||||
|
$needsUpdate = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tlsSni = $tlsSettings['server_name'] ?? null;
|
||||||
|
if (($tlsSni === null || $tlsSni === '') && $rootSni !== null && $rootSni !== '') {
|
||||||
|
$settings['tls_settings']['server_name'] = $rootSni;
|
||||||
|
$needsUpdate = true;
|
||||||
|
}
|
||||||
|
if (($tlsSettings['allow_insecure'] ?? null) === null && $rootInsecure) {
|
||||||
|
$settings['tls_settings']['allow_insecure'] = true;
|
||||||
|
$needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($needsUpdate) {
|
||||||
|
DB::table('v2_server')
|
||||||
|
->where('id', $server->id)
|
||||||
|
->update(['protocol_settings' => json_encode($settings)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
+23
-103
@@ -33,33 +33,20 @@ sudo bash quick_start.sh
|
|||||||
|
|
||||||
2. Configure Reverse Proxy:
|
2. Configure Reverse Proxy:
|
||||||
```nginx
|
```nginx
|
||||||
location /ws/ {
|
|
||||||
proxy_pass http://127.0.0.1:8076;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ / {
|
location ^~ / {
|
||||||
proxy_pass http://127.0.0.1:7001;
|
proxy_pass http://127.0.0.1:7001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Real-PORT $remote_port;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header Scheme $scheme;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header Server-Protocol $server_protocol;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Server-Name $server_name;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Server-Addr $server_addr;
|
proxy_set_header Connection $http_connection;
|
||||||
proxy_set_header Server-Port $server_port;
|
proxy_read_timeout 60s;
|
||||||
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
> The `/ws/` location enables WebSocket real-time node synchronization via `ws-server`. This service is enabled by default and can be toggled in Admin Panel > System Settings > Server.
|
> The all-in-one container's embedded Caddy fuses HTTP and the panel↔node WebSocket on port 7001. The single `Upgrade`/`Connection` pair above is enough; no separate `/ws/` location is needed. To opt out and expose Octane / `:8076` directly, set `ENABLE_CADDY=false` in `compose.yaml`.
|
||||||
|
|
||||||
3. Install Xboard:
|
3. Install Xboard:
|
||||||
```bash
|
```bash
|
||||||
@@ -74,85 +61,22 @@ yum update && yum install -y git
|
|||||||
|
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone -b compose --depth 1 https://github.com/cedar2025/Xboard ./
|
git clone -b compose --depth 1 https://github.com/cedar2025/Xboard ./
|
||||||
|
# (Optional shortcut: skip the clone and just fetch the sample file with
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/cedar2025/Xboard/master/compose.sample.yaml -o compose.yaml
|
||||||
|
# — the running PHP code is in the Docker image, not in the clone.)
|
||||||
|
|
||||||
# Configure Docker Compose
|
# Configure Docker Compose
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Edit compose.yaml:
|
4. Prepare `compose.yaml` from the **1Panel-specific** sample. This sample joins the external `1panel-network` so the container can reach the 1Panel-managed MySQL/Redis containers by their hostname:
|
||||||
```yaml
|
```bash
|
||||||
services:
|
cp compose.1panel.sample.yaml compose.yaml
|
||||||
web:
|
|
||||||
image: ghcr.io/cedar2025/xboard:new
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
- ./.env:/www/.env
|
|
||||||
- ./.docker/.data/:/www/.docker/.data
|
|
||||||
- ./storage/logs:/www/storage/logs
|
|
||||||
- ./storage/theme:/www/storage/theme
|
|
||||||
- ./plugins:/www/plugins
|
|
||||||
environment:
|
|
||||||
- docker=true
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
command: php artisan octane:start --host=0.0.0.0 --port=7001
|
|
||||||
restart: on-failure
|
|
||||||
ports:
|
|
||||||
- 7001:7001
|
|
||||||
networks:
|
|
||||||
- 1panel-network
|
|
||||||
|
|
||||||
horizon:
|
|
||||||
image: ghcr.io/cedar2025/xboard:new
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
- ./.env:/www/.env
|
|
||||||
- ./.docker/.data/:/www/.docker/.data
|
|
||||||
- ./storage/logs:/www/storage/logs
|
|
||||||
- ./plugins:/www/plugins
|
|
||||||
restart: on-failure
|
|
||||||
command: php artisan horizon
|
|
||||||
networks:
|
|
||||||
- 1panel-network
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
ws-server:
|
|
||||||
image: ghcr.io/cedar2025/xboard:new
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
- ./.env:/www/.env
|
|
||||||
- ./.docker/.data/:/www/.docker/.data
|
|
||||||
- ./storage/logs:/www/storage/logs
|
|
||||||
- ./plugins:/www/plugins
|
|
||||||
restart: on-failure
|
|
||||||
ports:
|
|
||||||
- 8076:8076
|
|
||||||
networks:
|
|
||||||
- 1panel-network
|
|
||||||
command: php artisan ws-server start
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- 1panel-network
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
redis-data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
1panel-network:
|
|
||||||
external: true
|
|
||||||
```
|
```
|
||||||
|
The file is gitignored so your edits survive `git pull`. See [docker-compose.md](./docker-compose.md) for tuning environment variables (`RESOURCE_PROFILE`, `ENABLE_HORIZON`, `ENABLE_REDIS`, etc.) and the other `compose.*.sample.yaml` alternatives.
|
||||||
|
|
||||||
5. Initialize Installation:
|
5. Initialize Installation:
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies and initialize
|
docker compose run -it --rm xboard php artisan xboard:install
|
||||||
docker compose run -it --rm web php artisan xboard:install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ Important Configuration Notes:
|
⚠️ Important Configuration Notes:
|
||||||
@@ -186,20 +110,16 @@ docker compose up -d
|
|||||||
|
|
||||||
## 4. Version Update
|
## 4. Version Update
|
||||||
|
|
||||||
> 💡 Important Note: The update command varies depending on your installation version:
|
|
||||||
> - If you installed recently (new version), use this command:
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull && \
|
docker compose pull && docker compose up -d
|
||||||
docker compose run -it --rm web php artisan xboard:update && \
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
> - If you installed earlier (old version), replace `web` with `xboard`:
|
|
||||||
```bash
|
The container always runs `php artisan xboard:update` (migrate + plugin install + version cache + theme refresh) on boot, so no extra command is required.
|
||||||
docker compose pull && \
|
|
||||||
docker compose run -it --rm xboard php artisan xboard:update && \
|
> **Using a `compose.yaml` from before 2026-04-19?** That template did not auto-run `xboard:update` on container start, so use the following command to upgrade instead:
|
||||||
docker compose up -d
|
> ```bash
|
||||||
```
|
> docker compose pull && docker compose run -it --rm web php artisan xboard:update && docker compose up -d
|
||||||
> 🤔 Not sure which to use? Try the new version command first, if it fails, use the old version command.
|
> ```
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
|
|||||||
@@ -65,14 +65,14 @@ cd /www/wwwroot/your-domain
|
|||||||
chattr -i .user.ini
|
chattr -i .user.ini
|
||||||
rm -rf .htaccess 404.html 502.html index.html .user.ini
|
rm -rf .htaccess 404.html 502.html index.html .user.ini
|
||||||
|
|
||||||
# Clone repository
|
# Clone the compose branch
|
||||||
git clone https://github.com/cedar2025/Xboard.git ./
|
git clone -b compose --depth 1 https://github.com/cedar2025/Xboard.git ./
|
||||||
|
|
||||||
# Prepare configuration file
|
# Prepare configuration file
|
||||||
cp compose.sample.yaml compose.yaml
|
cp compose.host.sample.yaml compose.yaml
|
||||||
|
|
||||||
# Install dependencies and initialize
|
# Install dependencies and initialize
|
||||||
docker compose run -it --rm web sh init.sh
|
docker compose run -it --rm xboard php artisan xboard:install
|
||||||
```
|
```
|
||||||
> ⚠️ Please save the admin dashboard URL, username, and password shown after installation
|
> ⚠️ Please save the admin dashboard URL, username, and password shown after installation
|
||||||
|
|
||||||
@@ -84,54 +84,35 @@ docker compose up -d
|
|||||||
#### 3.4 Configure Reverse Proxy
|
#### 3.4 Configure Reverse Proxy
|
||||||
Add the following content to your site configuration:
|
Add the following content to your site configuration:
|
||||||
```nginx
|
```nginx
|
||||||
location /ws/ {
|
|
||||||
proxy_pass http://127.0.0.1:8076;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ / {
|
location ^~ / {
|
||||||
proxy_pass http://127.0.0.1:7001;
|
proxy_pass http://127.0.0.1:7001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Real-PORT $remote_port;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header Scheme $scheme;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header Server-Protocol $server_protocol;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Server-Name $server_name;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Server-Addr $server_addr;
|
proxy_set_header Connection $http_connection;
|
||||||
proxy_set_header Server-Port $server_port;
|
proxy_read_timeout 60s;
|
||||||
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
> The `/ws/` location enables real-time node synchronization via `ws-server`. This service is enabled by default and can be toggled in Admin Panel > System Settings > Server.
|
> The all-in-one container's embedded Caddy fuses HTTP and the panel↔node WebSocket on port 7001. The single `Upgrade`/`Connection` pair above is enough; no separate `/ws/` location is needed. To opt out and expose Octane / `:8076` directly, set `ENABLE_CADDY=false` in `compose.yaml`.
|
||||||
|
|
||||||
## Maintenance Guide
|
## Maintenance Guide
|
||||||
|
|
||||||
### Version Updates
|
### Version Updates
|
||||||
|
|
||||||
> 💡 Important Note: Update commands may vary depending on your installed version:
|
|
||||||
> - For recent installations (new version), use:
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull && \
|
docker compose pull && docker compose up -d
|
||||||
docker compose run -it --rm web sh update.sh && \
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
> - For older installations, replace `web` with `xboard`:
|
|
||||||
```bash
|
The container always runs `php artisan xboard:update` (migrate + plugin install + version cache + theme refresh) on boot, so no extra command is required.
|
||||||
git config --global --add safe.directory $(pwd)
|
|
||||||
git fetch --all && git reset --hard origin/master && git pull origin master
|
> **Using a `compose.yaml` from before 2026-04-19?** That template did not auto-run `xboard:update` on container start, so use the following command to upgrade instead:
|
||||||
docker compose pull && \
|
> ```bash
|
||||||
docker compose run -it --rm xboard sh update.sh && \
|
> docker compose pull && docker compose run -it --rm web php artisan xboard:update && docker compose up -d
|
||||||
docker compose up -d
|
> ```
|
||||||
```
|
|
||||||
> 🤔 Not sure which to use? Try the new version command first, if it fails, use the old version command.
|
|
||||||
|
|
||||||
### Routine Maintenance
|
### Routine Maintenance
|
||||||
- Regular log checking: `docker compose logs`
|
- Regular log checking: `docker compose logs`
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ docker compose run -it --rm \
|
|||||||
-e ENABLE_SQLITE=true \
|
-e ENABLE_SQLITE=true \
|
||||||
-e ENABLE_REDIS=true \
|
-e ENABLE_REDIS=true \
|
||||||
-e ADMIN_ACCOUNT=admin@demo.com \
|
-e ADMIN_ACCOUNT=admin@demo.com \
|
||||||
web php artisan xboard:install
|
xboard php artisan xboard:install
|
||||||
```
|
```
|
||||||
- 自定义配置安装(高级用户)
|
- 自定义配置安装(高级用户)
|
||||||
```bash
|
```bash
|
||||||
docker compose run -it --rm web php artisan xboard:install
|
docker compose run -it --rm xboard php artisan xboard:install
|
||||||
```
|
```
|
||||||
> 请保存安装完成后显示的管理后台地址、用户名和密码
|
> 请保存安装完成后显示的管理后台地址、用户名和密码
|
||||||
|
|
||||||
@@ -52,9 +52,7 @@ docker compose up -d
|
|||||||
> - 如果是最近安装(新版本),使用:
|
> - 如果是最近安装(新版本),使用:
|
||||||
```bash
|
```bash
|
||||||
cd Xboard
|
cd Xboard
|
||||||
docker compose pull && \
|
docker compose pull && docker compose up -d
|
||||||
docker compose run -it --rm web php artisan xboard:update && \
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
> - 如果是较早安装(旧版本),请把 `web` 替换为 `xboard`:
|
> - 如果是较早安装(旧版本),请把 `web` 替换为 `xboard`:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,43 +1,37 @@
|
|||||||
<div style="background: #eee">
|
<!DOCTYPE html>
|
||||||
<table width="600" border="0" align="center" cellpadding="0" cellspacing="0">
|
<html lang="zh-CN">
|
||||||
<tbody>
|
<head>
|
||||||
<tr>
|
<meta charset="UTF-8">
|
||||||
<td>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<div style="background:#fff">
|
<title>邮箱登录</title>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</head>
|
||||||
<thead>
|
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||||
<tr>
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
|
||||||
<td valign="middle" style="padding-left:30px;background-color:#415A94;color:#fff;padding:20px 40px;font-size: 21px;">{{$name}}</td>
|
<tr><td align="center">
|
||||||
</tr>
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
|
||||||
</thead>
|
<!-- Logo -->
|
||||||
<tbody>
|
<tr><td style="padding-bottom:24px;text-align:center;">
|
||||||
<tr style="padding:40px 40px 0 40px;display:table-cell">
|
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
|
||||||
<td style="font-size:24px;line-height:1.5;color:#000;margin-top:40px">登入到{{$name}}</td>
|
</td></tr>
|
||||||
</tr>
|
<!-- Card -->
|
||||||
<tr>
|
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
|
||||||
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
尊敬的用户您好!
|
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">登录确认</td></tr>
|
||||||
<br />
|
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:28px;">点击下方按钮登录到 {{$name}},链接有效期 5 分钟。如非本人操作,请忽略此邮件。</td></tr>
|
||||||
<br />
|
<tr><td align="center" style="padding-bottom:28px;">
|
||||||
您正在登入到{{$name}}, 请在 5 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。
|
<a href="{{$link}}" style="display:inline-block;background:#18181b;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;padding:14px 36px;border-radius:8px;">确认登录</a>
|
||||||
<a href="{{$link}}">{{$link}}</a>
|
</td></tr>
|
||||||
</td>
|
<tr><td style="font-size:13px;color:#a1a1aa;line-height:1.5;">如果按钮无法点击,请复制以下链接到浏览器中打开:</td></tr>
|
||||||
</tr>
|
<tr><td style="font-size:13px;color:#71717a;line-height:1.5;word-break:break-all;padding-top:8px;">{{$link}}</td></tr>
|
||||||
<tr style="padding:40px;display:table-cell">
|
</table>
|
||||||
</tr>
|
</td></tr>
|
||||||
</tbody>
|
<!-- Footer -->
|
||||||
</table>
|
<tr><td style="padding-top:24px;text-align:center;">
|
||||||
</div>
|
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
|
||||||
<div>
|
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</td></tr>
|
||||||
<tbody>
|
</table>
|
||||||
<tr>
|
</td></tr>
|
||||||
<td style="padding:20px 40px;font-size:12px;color:#999;line-height:20px;background:#f7f7f7"><a href="{{$url}}" style="font-size:14px;color:#929292">返回{{$name}}</a></td>
|
</table>
|
||||||
</tr>
|
</body>
|
||||||
</tbody>
|
</html>
|
||||||
</table>
|
|
||||||
</div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,42 +1,35 @@
|
|||||||
<div style="background: #eee">
|
<!DOCTYPE html>
|
||||||
<table width="600" border="0" align="center" cellpadding="0" cellspacing="0">
|
<html lang="zh-CN">
|
||||||
<tbody>
|
<head>
|
||||||
<tr>
|
<meta charset="UTF-8">
|
||||||
<td>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<div style="background:#fff">
|
<title>网站通知</title>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</head>
|
||||||
<thead>
|
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||||
<tr>
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
|
||||||
<td valign="middle" style="padding-left:30px;background-color:#415A94;color:#fff;padding:20px 40px;font-size: 21px;">{{$name}}</td>
|
<tr><td align="center">
|
||||||
</tr>
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
|
||||||
</thead>
|
<!-- Logo -->
|
||||||
<tbody>
|
<tr><td style="padding-bottom:24px;text-align:center;">
|
||||||
<tr style="padding:40px 40px 0 40px;display:table-cell">
|
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
|
||||||
<td style="font-size:24px;line-height:1.5;color:#000;margin-top:40px">网站通知</td>
|
</td></tr>
|
||||||
</tr>
|
<!-- Card -->
|
||||||
<tr>
|
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
|
||||||
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
尊敬的用户您好!
|
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">网站通知</td></tr>
|
||||||
<br />
|
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:28px;">{!! nl2br($content) !!}</td></tr>
|
||||||
<br />
|
<tr><td align="center">
|
||||||
{!! nl2br($content) !!}
|
<a href="{{$url}}" style="display:inline-block;background:#18181b;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;padding:12px 28px;border-radius:8px;">前往查看</a>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
</table>
|
||||||
<tr style="padding:40px;display:table-cell">
|
</td></tr>
|
||||||
</tr>
|
<!-- Footer -->
|
||||||
</tbody>
|
<tr><td style="padding-top:24px;text-align:center;">
|
||||||
</table>
|
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
|
||||||
</div>
|
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||||
<div>
|
</td></tr>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</table>
|
||||||
<tbody>
|
</td></tr>
|
||||||
<tr>
|
</table>
|
||||||
<td style="padding:20px 40px;font-size:12px;color:#999;line-height:20px;background:#f7f7f7"><a href="{{$url}}" style="font-size:14px;color:#929292">返回{{$name}}</a></td>
|
</body>
|
||||||
</tr>
|
</html>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,42 +1,36 @@
|
|||||||
<div style="background: #eee">
|
<!DOCTYPE html>
|
||||||
<table width="600" border="0" align="center" cellpadding="0" cellspacing="0">
|
<html lang="zh-CN">
|
||||||
<tbody>
|
<head>
|
||||||
<tr>
|
<meta charset="UTF-8">
|
||||||
<td>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<div style="background:#fff">
|
<title>到期提醒</title>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</head>
|
||||||
<thead>
|
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||||
<tr>
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
|
||||||
<td valign="middle" style="padding-left:30px;background-color:#415A94;color:#fff;padding:20px 40px;font-size: 21px;">{{$name}}</td>
|
<tr><td align="center">
|
||||||
</tr>
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
|
||||||
</thead>
|
<!-- Logo -->
|
||||||
<tbody>
|
<tr><td style="padding-bottom:24px;text-align:center;">
|
||||||
<tr style="padding:40px 40px 0 40px;display:table-cell">
|
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
|
||||||
<td style="font-size:24px;line-height:1.5;color:#000;margin-top:40px">到期通知</td>
|
</td></tr>
|
||||||
</tr>
|
<!-- Card -->
|
||||||
<tr>
|
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
|
||||||
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
尊敬的用户您好!
|
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">订阅即将到期</td></tr>
|
||||||
<br />
|
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:12px;">您的订阅服务将在 <strong style="color:#18181b;">24 小时</strong>内到期。</td></tr>
|
||||||
<br />
|
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:28px;">为避免服务中断,请及时续费。如您已完成续费,请忽略此提醒。</td></tr>
|
||||||
你的服务将在24小时内到期。为了不造成使用上的影响请尽快续费。如果你已续费请忽略此邮件。
|
<tr><td align="center">
|
||||||
</td>
|
<a href="{{$url}}" style="display:inline-block;background:#18181b;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;padding:12px 28px;border-radius:8px;">立即续费</a>
|
||||||
</tr>
|
</td></tr>
|
||||||
<tr style="padding:40px;display:table-cell">
|
</table>
|
||||||
</tr>
|
</td></tr>
|
||||||
</tbody>
|
<!-- Footer -->
|
||||||
</table>
|
<tr><td style="padding-top:24px;text-align:center;">
|
||||||
</div>
|
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
|
||||||
<div>
|
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</td></tr>
|
||||||
<tbody>
|
</table>
|
||||||
<tr>
|
</td></tr>
|
||||||
<td style="padding:20px 40px;font-size:12px;color:#999;line-height:20px;background:#f7f7f7"><a href="{{$url}}" style="font-size:14px;color:#929292">返回{{$name}}</a></td>
|
</table>
|
||||||
</tr>
|
</body>
|
||||||
</tbody>
|
</html>
|
||||||
</table>
|
|
||||||
</div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,42 +1,36 @@
|
|||||||
<div style="background: #eee">
|
<!DOCTYPE html>
|
||||||
<table width="600" border="0" align="center" cellpadding="0" cellspacing="0">
|
<html lang="zh-CN">
|
||||||
<tbody>
|
<head>
|
||||||
<tr>
|
<meta charset="UTF-8">
|
||||||
<td>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<div style="background:#fff">
|
<title>流量提醒</title>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</head>
|
||||||
<thead>
|
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||||
<tr>
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
|
||||||
<td valign="middle" style="padding-left:30px;background-color:#415A94;color:#fff;padding:20px 40px;font-size: 21px;">{{$name}}</td>
|
<tr><td align="center">
|
||||||
</tr>
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
|
||||||
</thead>
|
<!-- Logo -->
|
||||||
<tbody>
|
<tr><td style="padding-bottom:24px;text-align:center;">
|
||||||
<tr style="padding:40px 40px 0 40px;display:table-cell">
|
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
|
||||||
<td style="font-size:24px;line-height:1.5;color:#000;margin-top:40px">流量通知</td>
|
</td></tr>
|
||||||
</tr>
|
<!-- Card -->
|
||||||
<tr>
|
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
|
||||||
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
尊敬的用户您好!
|
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">流量使用提醒</td></tr>
|
||||||
<br />
|
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:12px;">您本月的套餐流量已使用 <strong style="color:#18181b;">80%</strong>。</td></tr>
|
||||||
<br />
|
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:28px;">请合理安排使用,避免提前耗尽。如需更多流量,可前往面板升级套餐。</td></tr>
|
||||||
你的流量已经使用80%。为了不造成使用上的影响请合理安排流量的使用。
|
<tr><td align="center">
|
||||||
</td>
|
<a href="{{$url}}" style="display:inline-block;background:#18181b;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;padding:12px 28px;border-radius:8px;">查看用量</a>
|
||||||
</tr>
|
</td></tr>
|
||||||
<tr style="padding:40px;display:table-cell">
|
</table>
|
||||||
</tr>
|
</td></tr>
|
||||||
</tbody>
|
<!-- Footer -->
|
||||||
</table>
|
<tr><td style="padding-top:24px;text-align:center;">
|
||||||
</div>
|
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
|
||||||
<div>
|
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</td></tr>
|
||||||
<tbody>
|
</table>
|
||||||
<tr>
|
</td></tr>
|
||||||
<td style="padding:20px 40px;font-size:12px;color:#999;line-height:20px;background:#f7f7f7"><a href="{{$url}}" style="font-size:14px;color:#929292">返回{{$name}}</a></td>
|
</table>
|
||||||
</tr>
|
</body>
|
||||||
</tbody>
|
</html>
|
||||||
</table>
|
|
||||||
</div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,42 +1,36 @@
|
|||||||
<div style="background: #eee">
|
<!DOCTYPE html>
|
||||||
<table width="600" border="0" align="center" cellpadding="0" cellspacing="0">
|
<html lang="zh-CN">
|
||||||
<tbody>
|
<head>
|
||||||
<tr>
|
<meta charset="UTF-8">
|
||||||
<td>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<div style="background:#fff">
|
<title>邮箱验证码</title>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</head>
|
||||||
<thead>
|
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||||
<tr>
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
|
||||||
<td valign="middle" style="padding-left:30px;background-color:#415A94;color:#fff;padding:20px 40px;font-size: 21px;">{{$name}}</td>
|
<tr><td align="center">
|
||||||
</tr>
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
|
||||||
</thead>
|
<!-- Logo -->
|
||||||
<tbody>
|
<tr><td style="padding-bottom:24px;text-align:center;">
|
||||||
<tr style="padding:40px 40px 0 40px;display:table-cell">
|
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
|
||||||
<td style="font-size:24px;line-height:1.5;color:#000;margin-top:40px">邮箱验证码</td>
|
</td></tr>
|
||||||
</tr>
|
<!-- Card -->
|
||||||
<tr>
|
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
|
||||||
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
尊敬的用户您好!
|
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">邮箱验证码</td></tr>
|
||||||
<br />
|
<tr><td style="font-size:15px;color:#52525b;line-height:1.6;padding-bottom:28px;">请使用以下验证码完成验证,有效期 5 分钟。如非本人操作,请忽略此邮件。</td></tr>
|
||||||
<br />
|
<tr><td align="center" style="padding-bottom:28px;">
|
||||||
您的验证码是:{{$code}},请在 5 分钟内进行验证。如果该验证码不为您本人申请,请无视。
|
<div style="display:inline-block;background:#f4f4f5;border:1px solid #e4e4e7;border-radius:8px;padding:16px 40px;font-size:32px;font-weight:700;letter-spacing:6px;color:#18181b;font-family:'Courier New',Courier,monospace;">{{$code}}</div>
|
||||||
</td>
|
</td></tr>
|
||||||
</tr>
|
<tr><td style="font-size:13px;color:#a1a1aa;line-height:1.5;">如果您没有请求此验证码,无需进行任何操作。</td></tr>
|
||||||
<tr style="padding:40px;display:table-cell">
|
</table>
|
||||||
</tr>
|
</td></tr>
|
||||||
</tbody>
|
<!-- Footer -->
|
||||||
</table>
|
<tr><td style="padding-top:24px;text-align:center;">
|
||||||
</div>
|
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
|
||||||
<div>
|
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
</td></tr>
|
||||||
<tbody>
|
</table>
|
||||||
<tr>
|
</td></tr>
|
||||||
<td style="padding:20px 40px;font-size:12px;color:#999;line-height:20px;background:#f7f7f7"><a href="{{$url}}" style="font-size:14px;color:#929292">返回{{$name}}</a></td>
|
</table>
|
||||||
</tr>
|
</body>
|
||||||
</tbody>
|
</html>
|
||||||
</table>
|
|
||||||
</div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Server;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\ServerMachine;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ServerHandshakeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config()->set('app.key', 'base64:' . base64_encode(str_repeat('a', 32)));
|
||||||
|
Cache::forever('admin_settings', [
|
||||||
|
'server_token' => 'server-token',
|
||||||
|
'server_ws_enable' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v2_handshake_accepts_token_only_without_node(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v2/server/handshake', [
|
||||||
|
'token' => 'server-token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()->assertJsonStructure(['websocket' => ['enabled']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v2_handshake_rejects_invalid_token(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v2/server/handshake', [
|
||||||
|
'token' => 'wrong-token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v2_report_works_without_node_type(): void
|
||||||
|
{
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$server = $this->makeServer();
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v2/server/report', [
|
||||||
|
'token' => 'server-token',
|
||||||
|
'node_id' => $server->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()->assertJson(['data' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v2_report_ignores_node_type_field(): void
|
||||||
|
{
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$server = $this->makeServer();
|
||||||
|
|
||||||
|
// legacy node clients may still send node_type; V2 must accept it as no-op.
|
||||||
|
$response = $this->postJson('/api/v2/server/report', [
|
||||||
|
'token' => 'server-token',
|
||||||
|
'node_id' => $server->id,
|
||||||
|
'node_type' => 'this-would-be-rejected-by-v1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()->assertJson(['data' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v2_report_rejects_unknown_node(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v2/server/report', [
|
||||||
|
'token' => 'server-token',
|
||||||
|
'node_id' => 999999,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertJson(['message' => 'Server does not exist']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v2_machine_handshake_with_machine_id_and_no_node(): void
|
||||||
|
{
|
||||||
|
$machine = ServerMachine::create([
|
||||||
|
'name' => 'test-machine',
|
||||||
|
'token' => 'machine-token',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v2/server/handshake', [
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'token' => 'machine-token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v2_machine_report_requires_node_id(): void
|
||||||
|
{
|
||||||
|
$machine = ServerMachine::create([
|
||||||
|
'name' => 'test-machine',
|
||||||
|
'token' => 'machine-token',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v2/server/report', [
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'token' => 'machine-token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeServer(): Server
|
||||||
|
{
|
||||||
|
return Server::create([
|
||||||
|
'name' => 'test-node',
|
||||||
|
'type' => Server::TYPE_VMESS,
|
||||||
|
'host' => '127.0.0.1',
|
||||||
|
'port' => 443,
|
||||||
|
'server_port' => 443,
|
||||||
|
'rate' => '1',
|
||||||
|
'group_id' => [1],
|
||||||
|
'enabled' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user