merge: sync upstream/master from cedar2025/Xboard

合并上游 cedar2025/Xboard 的 master,并按交互决策保留本地改动。
This commit is contained in:
yinjianm
2026-03-19 21:04:27 +08:00
101 changed files with 3274 additions and 80278 deletions
+18 -1
View File
@@ -61,4 +61,21 @@ stopwaitsecs=3
stopsignal=TERM stopsignal=TERM
stopasgroup=true stopasgroup=true
killasgroup=true killasgroup=true
priority=300 priority=300
[program:ws-server]
process_name=%(program_name)s_%(process_num)02d
command=php /www/artisan ws-server start
autostart=%(ENV_ENABLE_WS_SERVER)s
autorestart=true
user=www
redirect_stderr=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stdout_logfile_backups=0
numprocs=1
stopwaitsecs=5
stopsignal=SIGINT
stopasgroup=true
killasgroup=true
priority=400
+1 -1
View File
@@ -1,5 +1,5 @@
APP_NAME=XBoard APP_NAME=XBoard
APP_ENV=local APP_ENV=production
APP_KEY=base64:PZXk5vTuTinfeEVG5FpYv2l6WEhLsyvGpiWK7IgJJ60= APP_KEY=base64:PZXk5vTuTinfeEVG5FpYv2l6WEhLsyvGpiWK7IgJJ60=
APP_DEBUG=false APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
+1 -10
View File
@@ -58,7 +58,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=sha,format=long type=sha,format=short,prefix=,enable=true
type=raw,value=new,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=new,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=${{ steps.get_version.outputs.version }} type=raw,value=${{ steps.get_version.outputs.version }}
@@ -98,12 +98,3 @@ jobs:
allow: | allow: |
network.host network.host
- name: Install cosign
uses: sigstore/cosign-installer@v3.4.0
with:
cosign-release: 'v2.2.2'
- name: Sign image
if: steps.build-and-push.outputs.digest != ''
run: |
echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign --yes "{}@${{ steps.build-and-push.outputs.digest }}"
+3
View File
@@ -0,0 +1,3 @@
[submodule "public/assets/admin"]
path = public/assets/admin
url = https://github.com/cedar2025/xboard-admin-dist.git
+6 -3
View File
@@ -25,12 +25,14 @@ RUN echo "Attempting to clone branch: ${BRANCH_NAME} from ${REPO_URL} with CACHE
rm -rf ./* && \ rm -rf ./* && \
rm -rf .git && \ rm -rf .git && \
git config --global --add safe.directory /www && \ git config --global --add safe.directory /www && \
git clone --depth 1 --branch ${BRANCH_NAME} ${REPO_URL} . git clone --depth 1 --branch ${BRANCH_NAME} ${REPO_URL} . && \
git submodule update --init --recursive --force
COPY .docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY .docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
RUN composer install --no-cache --no-dev \ RUN composer install --no-cache --no-dev \
&& 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 \
@@ -38,7 +40,8 @@ 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=false \
ENABLE_WS_SERVER=false
EXPOSE 7001 EXPOSE 7001
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
+2 -2
View File
@@ -104,9 +104,9 @@ class CheckCommission extends Command
$commissionBalance = $order->commission_balance * ($commissionShareLevels[$l] / 100); $commissionBalance = $order->commission_balance * ($commissionShareLevels[$l] / 100);
if (!$commissionBalance) continue; if (!$commissionBalance) continue;
if ((int)admin_setting('withdraw_close_enable', 0)) { if ((int)admin_setting('withdraw_close_enable', 0)) {
$inviter->balance = $inviter->balance + $commissionBalance; $inviter->increment('balance', $commissionBalance);
} else { } else {
$inviter->commission_balance = $inviter->commission_balance + $commissionBalance; $inviter->increment('commission_balance', $commissionBalance);
} }
if (!$inviter->save()) { if (!$inviter->save()) {
DB::rollBack(); DB::rollBack();
+5 -6
View File
@@ -43,12 +43,11 @@ class CheckOrder extends Command
*/ */
public function handle() public function handle()
{ {
ini_set('memory_limit', -1); Order::whereIn('status', [Order::STATUS_PENDING, Order::STATUS_PROCESSING])
$orders = Order::whereIn('status', [Order::STATUS_PENDING, Order::STATUS_PROCESSING])
->orderBy('created_at', 'ASC') ->orderBy('created_at', 'ASC')
->get(); ->lazyById(200)
foreach ($orders as $order) { ->each(function ($order) {
OrderHandleJob::dispatch($order->trade_no); OrderHandleJob::dispatch($order->trade_no);
} });
} }
} }
+7 -8
View File
@@ -38,15 +38,14 @@ class CheckTicket extends Command
*/ */
public function handle() public function handle()
{ {
ini_set('memory_limit', -1); Ticket::where('status', 0)
$tickets = Ticket::where('status', 0)
->where('updated_at', '<=', time() - 24 * 3600) ->where('updated_at', '<=', time() - 24 * 3600)
->where('reply_status', 0) ->where('reply_status', 0)
->get(); ->lazyById(200)
foreach ($tickets as $ticket) { ->each(function ($ticket) {
if ($ticket->user_id === $ticket->last_reply_user_id) continue; if ($ticket->user_id === $ticket->last_reply_user_id) return;
$ticket->status = Ticket::STATUS_CLOSED; $ticket->status = Ticket::STATUS_CLOSED;
$ticket->save(); $ticket->save();
} });
} }
} }
-53
View File
@@ -1,53 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class ExportV2Log extends Command
{
protected $signature = 'log:export {days=1 : The number of days to export logs for}';
protected $description = 'Export v2_log table records of the specified number of days to a file';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$days = $this->argument('days');
$date = Carbon::now()->subDays((float) $days)->startOfDay();
$logs = DB::table('v2_log')
->where('created_at', '>=', $date->timestamp)
->get();
$fileName = "v2_logs_" . Carbon::now()->format('Y_m_d_His') . ".csv";
$handle = fopen(storage_path("logs/$fileName"), 'w');
// 根据您的表结构
fputcsv($handle, ['Level', 'ID', 'Title', 'Host', 'URI', 'Method', 'Data', 'IP', 'Context', 'Created At', 'Updated At']);
foreach ($logs as $log) {
fputcsv($handle, [
$log->level,
$log->id,
$log->title,
$log->host,
$log->uri,
$log->method,
$log->data,
$log->ip,
$log->context,
Carbon::createFromTimestamp($log->created_at)->toDateTimeString(),
Carbon::createFromTimestamp($log->updated_at)->toDateTimeString()
]);
}
fclose($handle);
$this->info("日志成功导出到: " . storage_path("logs/$fileName"));
}
}
@@ -0,0 +1,279 @@
<?php
namespace App\Console\Commands;
use App\Models\Server;
use App\Services\NodeSyncService;
use App\Services\NodeRegistry;
use App\Services\ServerService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Workerman\Connection\TcpConnection;
use Workerman\Timer;
use Workerman\Worker;
class NodeWebSocketServer extends Command
{
protected $signature = 'ws-server
{action=start : start | stop | restart | reload | status}
{--d : Start in daemon mode}
{--host=0.0.0.0 : Listen address}
{--port=8076 : Listen port}';
protected $description = 'Start the WebSocket server for node-panel synchronization';
/** Auth timeout in seconds — close unauthenticated connections */
private const AUTH_TIMEOUT = 10;
/** Ping interval in seconds */
private const PING_INTERVAL = 55;
public function handle(): void
{
global $argv;
$action = $this->argument('action');
// 重新构建 argv 供 Workerman 解析
$argv[1] = $action;
if ($this->option('d')) {
$argv[2] = '-d';
}
$host = $this->option('host');
$port = $this->option('port');
$worker = new Worker("websocket://{$host}:{$port}");
$worker->count = 1;
$worker->name = 'xboard-ws-server';
// 设置日志和 PID 文件路径
$logPath = storage_path('logs');
if (!is_dir($logPath)) {
mkdir($logPath, 0777, true);
}
Worker::$logFile = $logPath . '/xboard-ws-server.log'; // 指向具体文件,避免某些环境 php://stdout 的 stat 失败
Worker::$pidFile = $logPath . '/xboard-ws-server.pid';
$worker->onWorkerStart = function (Worker $worker) {
$this->info("[WS] Worker started, pid={$worker->id}");
$this->subscribeRedis();
// Periodic ping to detect dead connections
Timer::add(self::PING_INTERVAL, function () {
foreach (NodeRegistry::getConnectedNodeIds() as $nodeId) {
$conn = NodeRegistry::get($nodeId);
if ($conn) {
$conn->send(json_encode(['event' => 'ping']));
}
}
});
};
$worker->onConnect = function (TcpConnection $conn) {
// Set auth timeout — must authenticate within N seconds or get disconnected
$conn->authTimer = Timer::add(self::AUTH_TIMEOUT, function () use ($conn) {
if (empty($conn->nodeId)) {
$conn->close(json_encode([
'event' => 'error',
'data' => ['message' => 'auth timeout'],
]));
}
}, [], false);
};
$worker->onWebSocketConnect = function (TcpConnection $conn, $httpMessage) {
// Parse query string from the WebSocket upgrade request
// In Workerman 4.x/5.x with onWebSocketConnect, the second arg can be a string or Request object
$queryString = '';
if (is_string($httpMessage)) {
$queryString = parse_url($httpMessage, PHP_URL_QUERY) ?? '';
} elseif ($httpMessage instanceof \Workerman\Protocols\Http\Request) {
$queryString = $httpMessage->queryString();
}
parse_str($queryString, $params);
$token = $params['token'] ?? '';
$nodeId = (int) ($params['node_id'] ?? 0);
// Authenticate
$serverToken = admin_setting('server_token', '');
if ($token === '' || $serverToken === '' || !hash_equals($serverToken, $token)) {
$conn->close(json_encode([
'event' => 'error',
'data' => ['message' => 'invalid token'],
]));
return;
}
$node = Server::find($nodeId);
if (!$node) {
$conn->close(json_encode([
'event' => 'error',
'data' => ['message' => 'node not found'],
]));
return;
}
// Auth passed — cancel timeout, register connection
if (isset($conn->authTimer)) {
Timer::del($conn->authTimer);
}
$conn->nodeId = $nodeId;
NodeRegistry::add($nodeId, $conn);
Cache::put("node_ws_alive:{$nodeId}", true, 86400);
Log::debug("[WS] Node#{$nodeId} connected", [
'remote' => $conn->getRemoteIp(),
'total' => NodeRegistry::count(),
]);
// Send auth success
$conn->send(json_encode([
'event' => 'auth.success',
'data' => ['node_id' => $nodeId],
]));
// Push full sync (config + users) immediately to this specific connection
$this->pushFullSync($conn, $node);
};
$worker->onMessage = function (TcpConnection $conn, $data) {
$msg = json_decode($data, true);
if (!is_array($msg)) {
return;
}
$event = $msg['event'] ?? '';
$nodeId = $conn->nodeId ?? null;
switch ($event) {
case 'pong':
// Heartbeat response — node is alive
if ($nodeId) {
Cache::put("node_ws_alive:{$nodeId}", true, 86400);
}
break;
case 'node.status':
if ($nodeId && isset($msg['data'])) {
$this->handleNodeStatus($nodeId, $msg['data']);
}
break;
default:
// Future: handle other node-initiated messages if needed
break;
}
};
$worker->onClose = function (TcpConnection $conn) {
if (!empty($conn->nodeId)) {
$nodeId = $conn->nodeId;
NodeRegistry::remove($nodeId);
Cache::forget("node_ws_alive:{$nodeId}");
Log::debug("[WS] Node#{$nodeId} disconnected", [
'total' => NodeRegistry::count(),
]);
}
};
Worker::runAll();
}
/**
* Handle status data pushed from node via WebSocket
*/
private function handleNodeStatus(int $nodeId, array $data): void
{
$node = Server::find($nodeId);
if (!$node) return;
$nodeType = strtoupper($node->type);
// Update last check-in cache
Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600);
// Update metrics cache via Service
ServerService::updateMetrics($node, $data);
Log::debug("[WS] Node#{$nodeId} status updated via WebSocket");
}
/**
* Subscribe to Redis pub/sub channel for receiving push commands from Laravel.
* Laravel app publishes to "node:push" channel, Workerman picks it up and forwards to the right node.
*/
private function subscribeRedis(): void
{
$host = config('database.redis.default.host', '127.0.0.1');
$port = config('database.redis.default.port', 6379);
// Handle Unix Socket connection
if (str_starts_with($host, '/')) {
$redisUri = "unix://{$host}";
} else {
$redisUri = "redis://{$host}:{$port}";
}
$redis = new \Workerman\Redis\Client($redisUri);
$password = config('database.redis.default.password');
if ($password) {
$redis->auth($password);
}
// Get Laravel Redis prefix to match publish()
$prefix = config('database.redis.options.prefix', '');
$channel = $prefix . 'node:push';
$redis->subscribe([$channel], function ($chan, $message) {
$payload = json_decode($message, true);
if (!is_array($payload)) {
return;
}
$nodeId = $payload['node_id'] ?? null;
$event = $payload['event'] ?? '';
$data = $payload['data'] ?? [];
if (!$nodeId || !$event) {
return;
}
$sent = NodeRegistry::send((int) $nodeId, $event, $data);
if ($sent) {
Log::debug("[WS] Pushed {$event} to node#{$nodeId}");
}
});
$this->info("[WS] Subscribed to Redis channel: {$channel}");
}
/**
* Push full config + users to a newly connected node.
*/
private function pushFullSync(TcpConnection $conn, Server $node): void
{
$nodeId = $conn->nodeId;
// Push config
$config = ServerService::buildNodeConfig($node);
Log::debug("[WS] Node#{$nodeId} config: ", $config);
$conn->send(json_encode([
'event' => 'sync.config',
'data' => ['config' => $config]
]));
// Push users
$users = ServerService::getAvailableUsers($node)->toArray();
$conn->send(json_encode([
'event' => 'sync.users',
'data' => ['users' => $users]
]));
Log::info("[WS] Full sync pushed to node#{$nodeId}", [
'users' => count($users),
]);
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Log; use App\Models\AdminAuditLog;
use App\Models\StatServer; use App\Models\StatServer;
use App\Models\StatUser; use App\Models\StatUser;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@@ -43,6 +43,6 @@ class ResetLog extends Command
{ {
StatUser::where('record_at', '<', strtotime('-2 month', time()))->delete(); StatUser::where('record_at', '<', strtotime('-2 month', time()))->delete();
StatServer::where('record_at', '<', strtotime('-2 month', time()))->delete(); StatServer::where('record_at', '<', strtotime('-2 month', time()))->delete();
Log::where('created_at', '<', strtotime('-1 month', time()))->delete(); AdminAuditLog::where('created_at', '<', strtotime('-3 month', time()))->delete();
} }
} }
+21 -53
View File
@@ -160,9 +160,7 @@ class XboardInstall extends Command
if (!self::registerAdmin($email, $password)) { if (!self::registerAdmin($email, $password)) {
abort(500, '管理员账号注册失败,请重试'); abort(500, '管理员账号注册失败,请重试');
} }
if (function_exists('exec')) { self::restoreProtectedPlugins($this);
self::restoreProtectedPlugins($this);
}
$this->info('正在安装默认插件...'); $this->info('正在安装默认插件...');
PluginManager::installDefaultPlugins(); PluginManager::installDefaultPlugins();
$this->info('默认插件安装完成'); $this->info('默认插件安装完成');
@@ -369,61 +367,31 @@ class XboardInstall extends Command
/** /**
* 还原内置受保护插件(可在安装和更新时调用) * 还原内置受保护插件(可在安装和更新时调用)
* Docker 部署时 plugins/ 目录被外部挂载覆盖,需要从镜像备份中还原默认插件
*/ */
public static function restoreProtectedPlugins(Command $console = null) public static function restoreProtectedPlugins(Command $console = null)
{ {
exec("git config core.filemode false", $output, $returnVar); $backupBase = '/opt/default-plugins';
$cmd = "git status --porcelain plugins/ 2>/dev/null"; $pluginsBase = base_path('plugins');
exec($cmd, $output, $returnVar);
if (!empty($output)) { if (!File::isDirectory($backupBase)) {
$hasNonNewFiles = false; $console?->info('非 Docker 环境或备份目录不存在,跳过插件还原。');
foreach ($output as $line) { return;
$status = trim(substr($line, 0, 2)); }
if ($status !== 'A') {
$hasNonNewFiles = true; foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
break; $dirName = Str::studly($pluginCode);
} $source = "{$backupBase}/{$dirName}";
$target = "{$pluginsBase}/{$dirName}";
if (!File::isDirectory($source)) {
continue;
} }
if ($hasNonNewFiles) {
if ($console)
$console->info("检测到 plugins 目录有变更,正在还原...");
foreach ($output as $line) { // 先清除旧文件再复制,避免重命名后残留旧文件
$status = trim(substr($line, 0, 2)); File::deleteDirectory($target);
$filePath = trim(substr($line, 3)); File::copyDirectory($source, $target);
$console?->info("已同步默认插件 [{$dirName}]");
if (strpos($filePath, 'plugins/') === 0 && $status !== 'A') {
$relativePath = substr($filePath, 8);
if ($console) {
$action = match ($status) {
'M' => '修改',
'D' => '删除',
'R' => '重命名',
'C' => '复制',
default => '变更'
};
$console->info("还原插件文件 [{$relativePath}] ({$action})");
}
$cmd = "git checkout HEAD -- {$filePath}";
exec($cmd, $gitOutput, $gitReturnVar);
if ($gitReturnVar === 0) {
if ($console)
$console->info("插件文件 [{$relativePath}] 已还原。");
} else {
if ($console)
$console->error("插件文件 [{$relativePath}] 还原失败。");
}
}
}
} else {
if ($console)
$console->info("plugins 目录状态正常,无需还原。");
}
} else {
if ($console)
$console->info("plugins 目录状态正常,无需还原。");
} }
} }
} }
+1 -1
View File
@@ -45,7 +45,7 @@ class XboardUpdate extends Command
public function handle() public function handle()
{ {
$this->info('正在导入数据库请稍等...'); $this->info('正在导入数据库请稍等...');
Artisan::call("migrate"); Artisan::call("migrate", ['--force' => true]);
$this->info(Artisan::output()); $this->info(Artisan::output());
$this->info('正在检查内置插件文件...'); $this->info('正在检查内置插件文件...');
XboardInstall::restoreProtectedPlugins($this); XboardInstall::restoreProtectedPlugins($this);
+4 -4
View File
@@ -32,11 +32,11 @@ class Kernel extends ConsoleKernel
// v2board // v2board
$schedule->command('xboard:statistics')->dailyAt('0:10')->onOneServer(); $schedule->command('xboard:statistics')->dailyAt('0:10')->onOneServer();
// check // check
$schedule->command('check:order')->everyMinute()->onOneServer(); $schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:commission')->everyMinute()->onOneServer(); $schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:ticket')->everyMinute()->onOneServer(); $schedule->command('check:ticket')->everyMinute()->onOneServer()->withoutOverlapping(5);
// reset // reset
$schedule->command('reset:traffic')->everyMinute()->onOneServer(); $schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10);
$schedule->command('reset:log')->daily()->onOneServer(); $schedule->command('reset:log')->daily()->onOneServer();
// send // send
$schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer(); $schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer();
+10 -1
View File
@@ -1,6 +1,5 @@
<?php <?php
use App\Support\Setting; use App\Support\Setting;
use Illuminate\Support\Facades\App;
if (!function_exists('admin_setting')) { if (!function_exists('admin_setting')) {
/** /**
@@ -28,6 +27,16 @@ if (!function_exists('admin_setting')) {
} }
} }
if (!function_exists('subscribe_template')) {
/**
* Get subscribe template content by protocol name.
*/
function subscribe_template(string $name): ?string
{
return \App\Models\SubscribeTemplate::getContent($name);
}
}
if (!function_exists('admin_settings_batch')) { if (!function_exists('admin_settings_batch')) {
/** /**
* 批量获取配置参数,性能优化版本 * 批量获取配置参数,性能优化版本
@@ -3,7 +3,8 @@
namespace App\Http\Controllers\V1\Server; namespace App\Http\Controllers\V1\Server;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\UpdateAliveDataJob; use App\Jobs\UserAliveSyncJob;
use App\Services\NodeSyncService;
use App\Services\ServerService; use App\Services\ServerService;
use App\Services\UserService; use App\Services\UserService;
use App\Utils\CacheKey; use App\Utils\CacheKey;
@@ -88,117 +89,13 @@ class UniProxyController extends Controller
public function config(Request $request) public function config(Request $request)
{ {
$node = $this->getNodeInfo($request); $node = $this->getNodeInfo($request);
$nodeType = $node->type; $response = ServerService::buildNodeConfig($node);
$protocolSettings = $node->protocol_settings;
$serverPort = $node->server_port;
$host = $node->host;
$baseConfig = [
'protocol' => $nodeType,
'listen_ip' => '0.0.0.0',
'server_port' => (int) $serverPort,
'network' => data_get($protocolSettings, 'network'),
'networkSettings' => data_get($protocolSettings, 'network_settings') ?: null,
];
$response = match ($nodeType) {
'shadowsocks' => [
...$baseConfig,
'cipher' => $protocolSettings['cipher'],
'plugin' => $protocolSettings['plugin'],
'plugin_opts' => $protocolSettings['plugin_opts'],
'server_key' => match ($protocolSettings['cipher']) {
'2022-blake3-aes-128-gcm' => Helper::getServerKey($node->created_at, 16),
'2022-blake3-aes-256-gcm' => Helper::getServerKey($node->created_at, 32),
default => null
}
],
'vmess' => [
...$baseConfig,
'tls' => (int) $protocolSettings['tls']
],
'trojan' => [
...$baseConfig,
'host' => $host,
'server_name' => $protocolSettings['server_name'],
],
'vless' => [
...$baseConfig,
'tls' => (int) $protocolSettings['tls'],
'flow' => $protocolSettings['flow'],
'tls_settings' =>
match ((int) $protocolSettings['tls']) {
2 => $protocolSettings['reality_settings'],
default => $protocolSettings['tls_settings']
}
],
'hysteria' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'version' => (int) $protocolSettings['version'],
'host' => $host,
'server_name' => $protocolSettings['tls']['server_name'],
'up_mbps' => (int) $protocolSettings['bandwidth']['up'],
'down_mbps' => (int) $protocolSettings['bandwidth']['down'],
...match ((int) $protocolSettings['version']) {
1 => ['obfs' => $protocolSettings['obfs']['password'] ?? null],
2 => [
'obfs' => $protocolSettings['obfs']['open'] ? $protocolSettings['obfs']['type'] : null,
'obfs-password' => $protocolSettings['obfs']['password'] ?? null
],
default => []
}
],
'tuic' => [
...$baseConfig,
'version' => (int) $protocolSettings['version'],
'server_port' => (int) $serverPort,
'server_name' => $protocolSettings['tls']['server_name'],
'congestion_control' => $protocolSettings['congestion_control'],
'auth_timeout' => '3s',
'zero_rtt_handshake' => false,
'heartbeat' => "3s",
],
'anytls' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'server_name' => $protocolSettings['tls']['server_name'],
'padding_scheme' => $protocolSettings['padding_scheme'],
],
'socks' => [
...$baseConfig,
'server_port' => (int) $serverPort,
],
'naive' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'tls' => (int) $protocolSettings['tls'],
'tls_settings' => $protocolSettings['tls_settings']
],
'http' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'tls' => (int) $protocolSettings['tls'],
'tls_settings' => $protocolSettings['tls_settings']
],
'mieru' => [
...$baseConfig,
'server_port' => (string) $serverPort,
'protocol' => (int) $protocolSettings['protocol'],
],
default => []
};
$response['base_config'] = [ $response['base_config'] = [
'push_interval' => (int) admin_setting('server_push_interval', 60), 'push_interval' => (int) admin_setting('server_push_interval', 60),
'pull_interval' => (int) admin_setting('server_pull_interval', 60) 'pull_interval' => (int) admin_setting('server_pull_interval', 60)
]; ];
if (!empty($node['route_ids'])) {
$response['routes'] = ServerService::getRoutes($node['route_ids']);
}
$eTag = sha1(json_encode($response)); $eTag = sha1(json_encode($response));
if (strpos($request->header('If-None-Match', ''), $eTag) !== false) { if (strpos($request->header('If-None-Match', ''), $eTag) !== false) {
return response(null, 304); return response(null, 304);
@@ -226,7 +123,7 @@ class UniProxyController extends Controller
'error' => 'Invalid online data' 'error' => 'Invalid online data'
], 400); ], 400);
} }
UpdateAliveDataJob::dispatch($data, $node->type, $node->id); UserAliveSyncJob::dispatch($data, $node->type, $node->id);
return response()->json(['data' => true]); return response()->json(['data' => true]);
} }
@@ -16,6 +16,7 @@ use App\Services\PaymentService;
use App\Services\PlanService; use App\Services\PlanService;
use App\Services\UserService; use App\Services\UserService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OrderController extends Controller class OrderController extends Controller
{ {
@@ -70,7 +70,7 @@ class TicketController extends Controller
if ($ticket->status) { if ($ticket->status) {
return $this->fail([400, __('The ticket is closed and cannot be replied')]); return $this->fail([400, __('The ticket is closed and cannot be replied')]);
} }
if ($request->user()->id == $this->getLastMessage($ticket->id)->user_id) { if ((int) admin_setting('ticket_must_wait_reply', 0) && $request->user()->id == $this->getLastMessage($ticket->id)->user_id) {
return $this->fail(codeResponse: [400, __('Please wait for the technical enginneer to reply')]); return $this->fail(codeResponse: [400, __('Please wait for the technical enginneer to reply')]);
} }
$ticketService = new TicketService(); $ticketService = new TicketService();
+25 -35
View File
@@ -18,6 +18,7 @@ use App\Utils\CacheKey;
use App\Utils\Helper; use App\Utils\Helper;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class UserController extends Controller class UserController extends Controller
{ {
@@ -31,20 +32,14 @@ class UserController extends Controller
public function getActiveSession(Request $request) public function getActiveSession(Request $request)
{ {
$user = User::find($request->user()->id); $user = $request->user();
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$authService = new AuthService($user); $authService = new AuthService($user);
return $this->success($authService->getSessions()); return $this->success($authService->getSessions());
} }
public function removeActiveSession(Request $request) public function removeActiveSession(Request $request)
{ {
$user = User::find($request->user()->id); $user = $request->user();
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$authService = new AuthService($user); $authService = new AuthService($user);
return $this->success($authService->removeSession($request->input('session_id'))); return $this->success($authService->removeSession($request->input('session_id')));
} }
@@ -62,10 +57,7 @@ class UserController extends Controller
public function changePassword(UserChangePassword $request) public function changePassword(UserChangePassword $request)
{ {
$user = User::find($request->user()->id); $user = $request->user();
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
if ( if (
!Helper::multiPasswordVerify( !Helper::multiPasswordVerify(
$user->password_algo, $user->password_algo,
@@ -163,10 +155,7 @@ class UserController extends Controller
public function resetSecurity(Request $request) public function resetSecurity(Request $request)
{ {
$user = User::find($request->user()->id); $user = $request->user();
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$user->uuid = Helper::guid(true); $user->uuid = Helper::guid(true);
$user->token = Helper::guid(); $user->token = Helper::guid();
if (!$user->save()) { if (!$user->save()) {
@@ -182,10 +171,7 @@ class UserController extends Controller
'remind_traffic' 'remind_traffic'
]); ]);
$user = User::find($request->user()->id); $user = $request->user();
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
try { try {
$user->update($updateData); $user->update($updateData);
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -197,27 +183,31 @@ class UserController extends Controller
public function transfer(UserTransfer $request) public function transfer(UserTransfer $request)
{ {
$user = User::find($request->user()->id); $amount = $request->input('transfer_amount');
if (!$user) { try {
return $this->fail([400, __('The user does not exist')]); DB::transaction(function () use ($request, $amount) {
} $user = User::lockForUpdate()->find($request->user()->id);
if ($request->input('transfer_amount') > $user->commission_balance) { if (!$user) {
return $this->fail([400, __('Insufficient commission balance')]); throw new \Exception(__('The user does not exist'));
} }
$user->commission_balance = $user->commission_balance - $request->input('transfer_amount'); if ($amount > $user->commission_balance) {
$user->balance = $user->balance + $request->input('transfer_amount'); throw new \Exception(__('Insufficient commission balance'));
if (!$user->save()) { }
return $this->fail([400, __('Transfer failed')]); $user->commission_balance -= $amount;
$user->balance += $amount;
if (!$user->save()) {
throw new \Exception(__('Transfer failed'));
}
});
} catch (\Exception $e) {
return $this->fail([400, $e->getMessage()]);
} }
return $this->success(true); return $this->success(true);
} }
public function getQuickLoginUrl(Request $request) public function getQuickLoginUrl(Request $request)
{ {
$user = User::find($request->user()->id); $user = $request->user();
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$url = $this->loginService->generateQuickLoginUrl($user, $request->input('redirect')); $url = $this->loginService->generateQuickLoginUrl($user, $request->input('redirect'));
return $this->success($url); return $this->success($url);
@@ -4,20 +4,12 @@ namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ConfigSave; use App\Http\Requests\Admin\ConfigSave;
use App\Protocols\Clash; use App\Models\SubscribeTemplate;
use App\Protocols\ClashMeta;
use App\Protocols\SingBox;
use App\Protocols\Stash;
use App\Protocols\Surfboard;
use App\Protocols\Surge;
use App\Services\MailService; use App\Services\MailService;
use App\Services\TelegramService; use App\Services\TelegramService;
use App\Services\ThemeService; use App\Services\ThemeService;
use App\Utils\Dict; use App\Utils\Dict;
use Illuminate\Console\Command;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
class ConfigController extends Controller class ConfigController extends Controller
{ {
@@ -57,31 +49,24 @@ class ConfigController extends Controller
'data' => $mailLog, 'data' => $mailLog,
]); ]);
} }
/**
* 获取规则模板内容
*
* @param string $file 文件路径
* @return string 文件内容
*/
private function getTemplateContent(string $file): string
{
$path = base_path($file);
return File::exists($path) ? File::get($path) : '';
}
public function setTelegramWebhook(Request $request) public function setTelegramWebhook(Request $request)
{ {
$app_url = admin_setting('app_url'); $hookUrl = $this->resolveTelegramWebhookUrl();
if (blank($app_url)) if (blank($hookUrl)) {
return $this->fail([422, '请先设置站点网址']); return $this->fail([422, 'Telegram Webhook地址未配置']);
$hookUrl = $app_url . '/api/v1/guest/telegram/webhook?' . http_build_query([ }
$hookUrl .= '?' . http_build_query([
'access_token' => md5(admin_setting('telegram_bot_token', $request->input('telegram_bot_token'))) 'access_token' => md5(admin_setting('telegram_bot_token', $request->input('telegram_bot_token')))
]); ]);
$telegramService = new TelegramService($request->input('telegram_bot_token')); $telegramService = new TelegramService($request->input('telegram_bot_token'));
$telegramService->getMe(); $telegramService->getMe();
$telegramService->setWebhook($hookUrl); $telegramService->setWebhook(url: $hookUrl);
$telegramService->registerBotCommands(); $telegramService->registerBotCommands();
return $this->success(true); return $this->success([
'success' => true,
'webhook_url' => $hookUrl,
'webhook_base_url' => $this->getTelegramWebhookBaseUrl(),
]);
} }
public function fetch(Request $request) public function fetch(Request $request)
@@ -131,6 +116,7 @@ class ConfigController extends Controller
'tos_url' => admin_setting('tos_url'), 'tos_url' => admin_setting('tos_url'),
'currency' => admin_setting('currency', 'CNY'), 'currency' => admin_setting('currency', 'CNY'),
'currency_symbol' => admin_setting('currency_symbol', '¥'), 'currency_symbol' => admin_setting('currency_symbol', '¥'),
'ticket_must_wait_reply' => (bool) admin_setting('ticket_must_wait_reply', 0),
], ],
'subscribe' => [ 'subscribe' => [
'plan_change_enable' => (bool) admin_setting('plan_change_enable', 1), 'plan_change_enable' => (bool) admin_setting('plan_change_enable', 1),
@@ -157,6 +143,8 @@ class ConfigController extends Controller
'server_pull_interval' => admin_setting('server_pull_interval', 60), 'server_pull_interval' => admin_setting('server_pull_interval', 60),
'server_push_interval' => admin_setting('server_push_interval', 60), 'server_push_interval' => admin_setting('server_push_interval', 60),
'device_limit_mode' => (int) admin_setting('device_limit_mode', 0), 'device_limit_mode' => (int) admin_setting('device_limit_mode', 0),
'server_ws_enable' => (bool) admin_setting('server_ws_enable', 1),
'server_ws_url' => admin_setting('server_ws_url', ''),
], ],
'email' => [ 'email' => [
'email_template' => admin_setting('email_template', 'default'), 'email_template' => admin_setting('email_template', 'default'),
@@ -171,6 +159,7 @@ class ConfigController extends Controller
'telegram' => [ 'telegram' => [
'telegram_bot_enable' => (bool) admin_setting('telegram_bot_enable', 0), 'telegram_bot_enable' => (bool) admin_setting('telegram_bot_enable', 0),
'telegram_bot_token' => admin_setting('telegram_bot_token'), 'telegram_bot_token' => admin_setting('telegram_bot_token'),
'telegram_webhook_url' => admin_setting('telegram_webhook_url'),
'telegram_discuss_link' => admin_setting('telegram_discuss_link') 'telegram_discuss_link' => admin_setting('telegram_discuss_link')
], ],
'app' => [ 'app' => [
@@ -208,14 +197,14 @@ class ConfigController extends Controller
], ],
'subscribe_template' => [ 'subscribe_template' => [
'subscribe_template_singbox' => $this->formatTemplateContent( 'subscribe_template_singbox' => $this->formatTemplateContent(
admin_setting('subscribe_template_singbox', $this->getDefaultTemplate('singbox')), subscribe_template('singbox') ?? '',
'json' 'json'
), ),
'subscribe_template_clash' => admin_setting('subscribe_template_clash', $this->getDefaultTemplate('clash')), 'subscribe_template_clash' => subscribe_template('clash') ?? '',
'subscribe_template_clashmeta' => admin_setting('subscribe_template_clashmeta', $this->getDefaultTemplate('clashmeta')), 'subscribe_template_clashmeta' => subscribe_template('clashmeta') ?? '',
'subscribe_template_stash' => admin_setting('subscribe_template_stash', $this->getDefaultTemplate('stash')), 'subscribe_template_stash' => subscribe_template('stash') ?? '',
'subscribe_template_surge' => admin_setting('subscribe_template_surge', $this->getDefaultTemplate('surge')), 'subscribe_template_surge' => subscribe_template('surge') ?? '',
'subscribe_template_surfboard' => admin_setting('subscribe_template_surfboard', $this->getDefaultTemplate('surfboard')) 'subscribe_template_surfboard' => subscribe_template('surfboard') ?? ''
] ]
]; ];
} }
@@ -224,7 +213,20 @@ class ConfigController extends Controller
{ {
$data = $request->validated(); $data = $request->validated();
$templateKeys = [
'subscribe_template_singbox' => 'singbox',
'subscribe_template_clash' => 'clash',
'subscribe_template_clashmeta' => 'clashmeta',
'subscribe_template_stash' => 'stash',
'subscribe_template_surge' => 'surge',
'subscribe_template_surfboard' => 'surfboard',
];
foreach ($data as $k => $v) { foreach ($data as $k => $v) {
if (isset($templateKeys[$k])) {
SubscribeTemplate::setContent($templateKeys[$k], $v);
continue;
}
if ($k == 'frontend_theme') { if ($k == 'frontend_theme') {
$themeService = app(ThemeService::class); $themeService = app(ThemeService::class);
$themeService->switch($v); $themeService->switch($v);
@@ -267,50 +269,32 @@ class ConfigController extends Controller
}; };
} }
/** private function getTelegramWebhookBaseUrl(): ?string
* 获取默认模板内容
*
* @param string $type 模板类型
* @return string 默认模板内容
*/
private function getDefaultTemplate(string $type): string
{ {
$fileMap = [ $customUrl = trim((string) admin_setting('telegram_webhook_url', ''));
'singbox' => [SingBox::CUSTOM_TEMPLATE_FILE, SingBox::DEFAULT_TEMPLATE_FILE], if ($customUrl !== '') {
'clash' => [Clash::CUSTOM_TEMPLATE_FILE, Clash::DEFAULT_TEMPLATE_FILE], return rtrim($customUrl, '/');
'clashmeta' => [
ClashMeta::CUSTOM_TEMPLATE_FILE,
ClashMeta::CUSTOM_CLASH_TEMPLATE_FILE,
ClashMeta::DEFAULT_TEMPLATE_FILE
],
'stash' => [
Stash::CUSTOM_TEMPLATE_FILE,
Stash::CUSTOM_CLASH_TEMPLATE_FILE,
Stash::DEFAULT_TEMPLATE_FILE
],
'surge' => [Surge::CUSTOM_TEMPLATE_FILE, Surge::DEFAULT_TEMPLATE_FILE],
'surfboard' => [Surfboard::CUSTOM_TEMPLATE_FILE, Surfboard::DEFAULT_TEMPLATE_FILE],
];
if (!isset($fileMap[$type])) {
return '';
} }
// 按优先级查找可用的模板文件 $appUrl = trim((string) admin_setting('app_url', ''));
foreach ($fileMap[$type] as $file) { if ($appUrl !== '') {
$content = $this->getTemplateContent($file); return rtrim($appUrl, '/');
if (!empty($content)) {
// 对于 SingBox,需要格式化 JSON
if ($type === 'singbox') {
$decoded = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE) {
return json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
return $content;
}
} }
return ''; return null;
}
private function resolveTelegramWebhookUrl(): ?string
{
$baseUrl = $this->getTelegramWebhookBaseUrl();
if (!$baseUrl) {
return null;
}
if (str_contains($baseUrl, '/api/v1/guest/telegram/webhook')) {
return $baseUrl;
}
return $baseUrl . '/api/v1/guest/telegram/webhook';
} }
} }
@@ -84,7 +84,12 @@ class ManageController extends Controller
'show' => 'integer', 'show' => 'integer',
]); ]);
if (!Server::where('id', $request->id)->update(['show' => $request->show])) { $server = Server::find($request->id);
if (!$server) {
return $this->fail([400202, '服务器不存在']);
}
$server->show = (int) $request->show;
if (!$server->save()) {
return $this->fail([500, '保存失败']); return $this->fail([500, '保存失败']);
} }
return $this->success(true); return $this->success(true);
@@ -23,7 +23,7 @@ class RouteController extends Controller
$params = $request->validate([ $params = $request->validate([
'remarks' => 'required', 'remarks' => 'required',
'match' => 'required|array', 'match' => 'required|array',
'action' => 'required|in:block,dns', 'action' => 'required|in:block,direct,dns,proxy',
'action_value' => 'nullable' 'action_value' => 'nullable'
], [ ], [
'remarks.required' => '备注不能为空', 'remarks.required' => '备注不能为空',
@@ -536,19 +536,20 @@ class StatController extends Controller
} }
$result = []; $result = [];
$ids = $currentData->pluck('id');
$names = $type === 'node'
? Server::whereIn('id', $ids)->pluck('name', 'id')
: User::whereIn('id', $ids)->pluck('email', 'id');
foreach ($currentData as $data) { foreach ($currentData as $data) {
$previousValue = isset($previousData[$data->id]) ? $previousData[$data->id]->value : 0; $previousValue = isset($previousData[$data->id]) ? $previousData[$data->id]->value : 0;
$change = $previousValue > 0 ? round(($data->value - $previousValue) / $previousValue * 100, 1) : 0; $change = $previousValue > 0 ? round(($data->value - $previousValue) / $previousValue * 100, 1) : 0;
$name = $type === 'node'
? optional(Server::find($data->id))->name ?? "Node {$data->id}"
: optional(User::find($data->id))->email ?? "User {$data->id}";
$result[] = [ $result[] = [
'id' => (string) $data->id, 'id' => (string) $data->id,
'name' => $name, 'name' => $names[$data->id] ?? ($type === 'node' ? "Node {$data->id}" : "User {$data->id}"),
'value' => $data->value, // Convert to GB 'value' => $data->value,
'previousValue' => $previousValue, // Convert to GB 'previousValue' => $previousValue,
'change' => $change, 'change' => $change,
'timestamp' => date('c', $endDate) 'timestamp' => date('c', $endDate)
]; ];
@@ -3,7 +3,7 @@
namespace App\Http\Controllers\V2\Admin; namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Log as LogModel; use App\Models\AdminAuditLog;
use App\Utils\CacheKey; use App\Utils\CacheKey;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -23,37 +23,10 @@ class SystemController extends Controller
'schedule' => $this->getScheduleStatus(), 'schedule' => $this->getScheduleStatus(),
'horizon' => $this->getHorizonStatus(), 'horizon' => $this->getHorizonStatus(),
'schedule_last_runtime' => Cache::get(CacheKey::get('SCHEDULE_LAST_CHECK_AT', null)), 'schedule_last_runtime' => Cache::get(CacheKey::get('SCHEDULE_LAST_CHECK_AT', null)),
'logs' => $this->getLogStatistics()
]; ];
return $this->success($data); return $this->success($data);
} }
/**
* 获取日志统计信息
*
* @return array 各级别日志的数量统计
*/
protected function getLogStatistics(): array
{
// 初始化日志统计数组
$statistics = [
'info' => 0,
'warning' => 0,
'error' => 0,
'total' => 0
];
if (class_exists(LogModel::class) && LogModel::count() > 0) {
$statistics['info'] = LogModel::where('level', 'INFO')->count();
$statistics['warning'] = LogModel::where('level', 'WARNING')->count();
$statistics['error'] = LogModel::where('level', 'ERROR')->count();
$statistics['total'] = LogModel::count();
return $statistics;
}
return $statistics;
}
public function getQueueWorkload(WorkloadRepository $workload) public function getQueueWorkload(WorkloadRepository $workload)
{ {
return $this->success(collect($workload->get())->sortBy('name')->values()->toArray()); return $this->success(collect($workload->get())->sortBy('name')->values()->toArray());
@@ -125,34 +98,26 @@ class SystemController extends Controller
})->count(); })->count();
} }
public function getSystemLog(Request $request) public function getAuditLog(Request $request)
{ {
$current = $request->input('current') ? $request->input('current') : 1; $current = max(1, (int) $request->input('current', 1));
$pageSize = $request->input('page_size') >= 10 ? $request->input('page_size') : 10; $pageSize = max(10, (int) $request->input('page_size', 10));
$level = $request->input('level');
$keyword = $request->input('keyword');
$builder = LogModel::orderBy('created_at', 'DESC') $builder = AdminAuditLog::with('admin:id,email')
->when($level, function ($query) use ($level) { ->orderBy('id', 'DESC')
return $query->where('level', strtoupper($level)); ->when($request->input('action'), fn($q, $v) => $q->where('action', $v))
}) ->when($request->input('admin_id'), fn($q, $v) => $q->where('admin_id', $v))
->when($keyword, function ($query) use ($keyword) { ->when($request->input('keyword'), function ($q, $keyword) {
return $query->where(function ($q) use ($keyword) { $q->where(function ($q) use ($keyword) {
$q->where('data', 'like', '%' . $keyword . '%') $q->where('uri', 'like', '%' . $keyword . '%')
->orWhere('context', 'like', '%' . $keyword . '%') ->orWhere('request_data', 'like', '%' . $keyword . '%');
->orWhere('title', 'like', '%' . $keyword . '%')
->orWhere('uri', 'like', '%' . $keyword . '%');
}); });
}); });
$total = $builder->count(); $total = $builder->count();
$res = $builder->forPage($current, $pageSize) $res = $builder->forPage($current, $pageSize)->get();
->get();
return response([ return response(['data' => $res, 'total' => $total]);
'data' => $res,
'total' => $total
]);
} }
public function getHorizonFailedJobs(Request $request, JobRepository $jobRepository) public function getHorizonFailedJobs(Request $request, JobRepository $jobRepository)
@@ -176,125 +141,4 @@ class SystemController extends Controller
]); ]);
} }
/**
* 清除系统日志
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function clearSystemLog(Request $request)
{
$request->validate([
'days' => 'integer|min:0|max:365',
'level' => 'string|in:info,warning,error,all',
'limit' => 'integer|min:100|max:10000'
], [
'days.required' => '请指定要清除多少天前的日志',
'days.integer' => '天数必须为整数',
'days.min' => '天数不能少于1天',
'days.max' => '天数不能超过365天',
'level.in' => '日志级别只能是:info、warning、error、all',
'limit.min' => '单次清除数量不能少于100条',
'limit.max' => '单次清除数量不能超过10000条'
]);
$days = $request->input('days', 30); // 默认清除30天前的日志
$level = $request->input('level', 'all'); // 默认清除所有级别
$limit = $request->input('limit', 1000); // 默认单次清除1000条
try {
$cutoffDate = now()->subDays($days);
// 构建查询条件
$query = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$query->where('level', strtoupper($level));
}
// 获取要删除的记录数量
$totalCount = $query->count();
if ($totalCount === 0) {
return $this->success([
'message' => '没有找到符合条件的日志记录',
'deleted_count' => 0,
'total_count' => $totalCount
]);
}
// 分批删除,避免单次删除过多数据
$deletedCount = 0;
$batchSize = min($limit, 1000); // 每批最多1000条
while ($deletedCount < $limit && $deletedCount < $totalCount) {
$remainingLimit = min($batchSize, $limit - $deletedCount);
$batchQuery = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$batchQuery->where('level', strtoupper($level));
}
$idsToDelete = $batchQuery->limit($remainingLimit)->pluck('id');
if ($idsToDelete->isEmpty()) {
break;
}
$batchDeleted = LogModel::whereIn('id', $idsToDelete)->delete();
$deletedCount += $batchDeleted;
// 避免长时间占用数据库连接
if ($deletedCount < $limit && $deletedCount < $totalCount) {
usleep(100000); // 暂停0.1秒
}
}
return $this->success([
'message' => '日志清除完成',
'deleted_count' => $deletedCount,
'total_count' => $totalCount,
'remaining_count' => max(0, $totalCount - $deletedCount)
]);
} catch (\Exception $e) {
return $this->fail(ResponseEnum::HTTP_ERROR, null, '清除日志失败:' . $e->getMessage());
}
}
/**
* 获取日志清除统计信息
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getLogClearStats(Request $request)
{
$days = $request->input('days', 30);
$level = $request->input('level', 'all');
try {
$cutoffDate = now()->subDays($days);
$query = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$query->where('level', strtoupper($level));
}
$stats = [
'days' => $days,
'level' => $level,
'cutoff_date' => $cutoffDate->format(format: 'Y-m-d H:i:s'),
'total_logs' => LogModel::count(),
'logs_to_clear' => $query->count(),
'oldest_log' => LogModel::orderBy('created_at', 'asc')->first(),
'newest_log' => LogModel::orderBy('created_at', 'desc')->first(),
];
return $this->success($stats);
} catch (\Exception $e) {
return $this->fail(ResponseEnum::HTTP_ERROR, null, '获取统计信息失败:' . $e->getMessage());
}
}
} }
+141 -72
View File
@@ -10,10 +10,12 @@ use App\Jobs\SendEmailJob;
use App\Models\Plan; use App\Models\Plan;
use App\Models\User; use App\Models\User;
use App\Services\AuthService; use App\Services\AuthService;
use App\Services\NodeSyncService;
use App\Services\UserService; use App\Services\UserService;
use App\Traits\QueryOperators; use App\Traits\QueryOperators;
use App\Utils\Helper; use App\Utils\Helper;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -34,27 +36,15 @@ class UserController extends Controller
return $this->success($user->save()); return $this->success($user->save());
} }
/** // Apply filters and sorts to the query builder.
* Apply filters and sorts to the query builder private function applyFiltersAndSorts(Request $request, Builder|QueryBuilder $builder): void
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applyFiltersAndSorts(Request $request, Builder $builder): void
{ {
$this->applyFilters($request, $builder); $this->applyFilters($request, $builder);
$this->applySorting($request, $builder); $this->applySorting($request, $builder);
} }
/** // Apply filters to the query builder.
* Apply filters to the query builder private function applyFilters(Request $request, Builder|QueryBuilder $builder): void
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applyFilters(Request $request, Builder $builder): void
{ {
if (!$request->has('filter')) { if (!$request->has('filter')) {
return; return;
@@ -70,18 +60,14 @@ class UserController extends Controller
}); });
} }
/** // Build one filter query condition.
* Build the filter query based on field and value private function buildFilterQuery(Builder|QueryBuilder $query, string $field, mixed $value): void
*
* @param Builder $query
* @param string $field
* @param mixed $value
* @return void
*/
private function buildFilterQuery(Builder $query, string $field, mixed $value): void
{ {
// 处理关联查询 // 处理关联查询
if (str_contains($field, '.')) { if (str_contains($field, '.')) {
if (!method_exists($query, 'whereHas')) {
return;
}
[$relation, $relationField] = explode('.', $field); [$relation, $relationField] = explode('.', $field);
$query->whereHas($relation, function ($q) use ($relationField, $value) { $query->whereHas($relation, function ($q) use ($relationField, $value) {
if (is_array($value)) { if (is_array($value)) {
@@ -126,14 +112,8 @@ class UserController extends Controller
$this->applyQueryCondition($query, $queryField, $operator, $filterValue); $this->applyQueryCondition($query, $queryField, $operator, $filterValue);
} }
/** // Apply sorting rules to the query builder.
* Apply sorting to the query builder private function applySorting(Request $request, Builder|QueryBuilder $builder): void
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applySorting(Request $request, Builder $builder): void
{ {
if (!$request->has('sort')) { if (!$request->has('sort')) {
return; return;
@@ -146,19 +126,50 @@ class UserController extends Controller
}); });
} }
/** // Resolve bulk operation scope and normalize user_ids.
* Fetch paginated user list with filters and sorting private function resolveScope(Request $request): array
* {
* @param Request $request $scope = $request->input('scope');
* @return \Illuminate\Http\Response $userIds = $request->input('user_ids');
*/
$hasSelection = is_array($userIds) && count(array_filter($userIds, static fn($v) => is_numeric($v))) > 0;
$hasFilter = $request->has('filter') && !empty($request->input('filter'));
if (!in_array($scope, ['selected', 'filtered', 'all'], true)) {
if ($hasSelection) {
$scope = 'selected';
} elseif ($hasFilter) {
$scope = 'filtered';
} else {
$scope = 'all';
}
}
$normalizedIds = [];
if ($scope === 'selected') {
$normalizedIds = is_array($userIds) ? $userIds : [];
$normalizedIds = array_values(array_unique(array_map(static function ($v) {
return is_numeric($v) ? (int) $v : null;
}, $normalizedIds)));
$normalizedIds = array_values(array_filter($normalizedIds, static fn($v) => is_int($v)));
}
return [
'scope' => $scope,
'user_ids' => $normalizedIds,
];
}
// Fetch paginated user list (filters + sorting).
public function fetch(Request $request) public function fetch(Request $request)
{ {
$current = $request->input('current', 1); $current = $request->input('current', 1);
$pageSize = $request->input('pageSize', 10); $pageSize = $request->input('pageSize', 10);
$userModel = User::with(['plan:id,name', 'invite_user:id,email', 'group:id,name']) $userModel = User::query()
->select(DB::raw('*, (u+d) as total_used')); ->with(['plan:id,name', 'invite_user:id,email', 'group:id,name'])
->select((new User())->getTable() . '.*')
->selectRaw('(u + d) as total_used');
$this->applyFiltersAndSorts($request, $userModel); $this->applyFiltersAndSorts($request, $userModel);
@@ -172,12 +183,7 @@ class UserController extends Controller
return $this->paginate($users); return $this->paginate($users);
} }
/** // Transform user fields for API response.
* Transform user data for response
*
* @param User $user
* @return array<string, mixed>
*/
public static function transformUserData(User $user): array public static function transformUserData(User $user): array
{ {
$user = $user->toArray(); $user = $user->toArray();
@@ -253,19 +259,25 @@ class UserController extends Controller
return $this->success(true); return $this->success(true);
} }
/** // Export users to CSV.
* 导出用户数据为CSV格式
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\StreamedResponse
*/
public function dumpCSV(Request $request) public function dumpCSV(Request $request)
{ {
ini_set('memory_limit', '-1'); ini_set('memory_limit', '-1');
gc_enable(); // 启用垃圾回收 gc_enable(); // 启用垃圾回收
$scopeInfo = $this->resolveScope($request);
$scope = $scopeInfo['scope'];
$userIds = $scopeInfo['user_ids'];
if ($scope === 'selected') {
if (empty($userIds)) {
return $this->fail([422, 'user_ids不能为空']);
}
}
// 优化查询:使用with预加载plan关系,避免N+1问题 // 优化查询:使用with预加载plan关系,避免N+1问题
$query = User::with('plan:id,name') $query = User::query()
->with('plan:id,name')
->orderBy('id', 'asc') ->orderBy('id', 'asc')
->select([ ->select([
'email', 'email',
@@ -279,7 +291,11 @@ class UserController extends Controller
'plan_id' 'plan_id'
]); ]);
$this->applyFiltersAndSorts($request, $query); if ($scope === 'selected') {
$query->whereIn('id', $userIds);
} elseif ($scope === 'filtered') {
$this->applyFiltersAndSorts($request, $query);
} // all: ignore filter/sort
$filename = 'users_' . date('Y-m-d_His') . '.csv'; $filename = 'users_' . date('Y-m-d_His') . '.csv';
@@ -439,26 +455,65 @@ class UserController extends Controller
public function sendMail(UserSendMail $request) public function sendMail(UserSendMail $request)
{ {
ini_set('memory_limit', '-1'); ini_set('memory_limit', '-1');
$scopeInfo = $this->resolveScope($request);
$scope = $scopeInfo['scope'];
$userIds = $scopeInfo['user_ids'];
if ($scope === 'selected') {
if (empty($userIds)) {
return $this->fail([422, 'user_ids不能为空']);
}
}
$sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC'; $sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC';
$sort = $request->input('sort') ? $request->input('sort') : 'created_at'; $sort = $request->input('sort') ? $request->input('sort') : 'created_at';
$hourlyLimit = (int) env('MASS_EMAIL_HOURLY_LIMIT', 500); $hourlyLimit = (int) env('MASS_EMAIL_HOURLY_LIMIT', 500);
$hourlyLimit = $hourlyLimit > 0 ? $hourlyLimit : 500; $hourlyLimit = $hourlyLimit > 0 ? $hourlyLimit : 500;
$builder = User::orderBy($sort, $sortType);
$this->applyFiltersAndSorts($request, $builder); $builder = User::query()
->with('plan:id,name')
->orderBy('id', 'desc');
if ($scope === 'filtered') {
// filtered: apply filters/sort
$builder->orderBy($sort, $sortType);
$this->applyFiltersAndSorts($request, $builder);
} elseif ($scope === 'selected') {
$builder->whereIn('id', $userIds);
} // all: ignore filter/sort
$subject = $request->input('subject'); $subject = $request->input('subject');
$content = $request->input('content'); $content = $request->input('content');
$templateValue = [ $appName = admin_setting('app_name', 'Notification Service');
'name' => admin_setting('app_name', 'Notification Service'), $appUrl = admin_setting('app_url');
'url' => admin_setting('app_url'),
'content' => $content
];
$chunkSize = 1000; $chunkSize = 1000;
$processed = 0; $processed = 0;
$builder->chunk($chunkSize, function ($users) use ($subject, $templateValue, $hourlyLimit, &$processed) { $builder->chunk($chunkSize, function ($users) use ($subject, $content, $appName, $appUrl, $hourlyLimit, &$processed) {
foreach ($users as $user) { foreach ($users as $user) {
$vars = [
'app.name' => $appName,
'app.url' => $appUrl,
'now' => now()->format('Y-m-d H:i:s'),
'user.id' => $user->id,
'user.email' => $user->email,
'user.uuid' => $user->uuid,
'user.plan_name' => $user->plan?->name ?? '',
'user.expired_at' => $user->expired_at ? date('Y-m-d H:i:s', $user->expired_at) : '',
'user.transfer_enable' => (int) ($user->transfer_enable ?? 0),
'user.transfer_used' => (int) (($user->u ?? 0) + ($user->d ?? 0)),
'user.transfer_left' => (int) (($user->transfer_enable ?? 0) - (($user->u ?? 0) + ($user->d ?? 0))),
];
$templateValue = [
'name' => $appName,
'url' => $appUrl,
'content' => $content,
'vars' => $vars,
'content_mode' => 'text',
];
$delaySeconds = intdiv($processed, $hourlyLimit) * 3600; $delaySeconds = intdiv($processed, $hourlyLimit) * 3600;
dispatch(new SendEmailJob([ dispatch(new SendEmailJob([
'email' => $user->email, 'email' => $user->email,
@@ -475,10 +530,29 @@ class UserController extends Controller
public function ban(Request $request) public function ban(Request $request)
{ {
$scopeInfo = $this->resolveScope($request);
$scope = $scopeInfo['scope'];
$userIds = $scopeInfo['user_ids'];
if ($scope === 'selected') {
if (empty($userIds)) {
return $this->fail([422, 'user_ids不能为空']);
}
}
$sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC'; $sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC';
$sort = $request->input('sort') ? $request->input('sort') : 'created_at'; $sort = $request->input('sort') ? $request->input('sort') : 'created_at';
$builder = User::orderBy($sort, $sortType);
$this->applyFilters($request, $builder); $builder = User::query()->orderBy('id', 'desc');
if ($scope === 'filtered') {
// filtered: keep current semantics
$builder->orderBy($sort, $sortType);
$this->applyFiltersAndSorts($request, $builder);
} elseif ($scope === 'selected') {
$builder->whereIn('id', $userIds);
} // all: ignore filter/sort
try { try {
$builder->update([ $builder->update([
'banned' => 1 'banned' => 1
@@ -487,16 +561,11 @@ class UserController extends Controller
Log::error($e); Log::error($e);
return $this->fail([500, '处理失败']); return $this->fail([500, '处理失败']);
} }
// Full refresh not implemented.
return $this->success(true); return $this->success(true);
} }
/** // Delete user and related data.
* 删除用户及其关联数据
*
* @param Request $request
* @return JsonResponse
*/
public function destroy(Request $request) public function destroy(Request $request)
{ {
$request->validate([ $request->validate([
@@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers\V2\Server;
use App\Http\Controllers\Controller;
use App\Jobs\UserAliveSyncJob;
use App\Services\ServerService;
use App\Services\UserService;
use App\Utils\CacheKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\JsonResponse;
use Log;
class ServerController extends Controller
{
/**
* server handshake api
*/
public function handshake(Request $request): JsonResponse
{
$websocket = ['enabled' => false];
if ((bool) admin_setting('server_ws_enable', 1)) {
$customUrl = trim((string) admin_setting('server_ws_url', ''));
if ($customUrl !== '') {
$wsUrl = rtrim($customUrl, '/');
} else {
$wsScheme = $request->isSecure() ? 'wss' : 'ws';
$wsUrl = "{$wsScheme}://{$request->getHost()}:8076";
}
$websocket = [
'enabled' => true,
'ws_url' => $wsUrl,
];
}
return response()->json([
'websocket' => $websocket
]);
}
/**
* node report api - merge traffic + alive + status
* POST /api/v2/server/node/report
*/
public function report(Request $request): JsonResponse
{
$node = $request->attributes->get('node_info');
$nodeType = $node->type;
$nodeId = $node->id;
Cache::put(CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_CHECK_AT', $nodeId), time(), 3600);
// hanle traffic data
$traffic = $request->input('traffic');
if (is_array($traffic) && !empty($traffic)) {
$data = array_filter($traffic, function ($item) {
return is_array($item)
&& count($item) === 2
&& is_numeric($item[0])
&& is_numeric($item[1]);
});
if (!empty($data)) {
Cache::put(
CacheKey::get('SERVER_' . strtoupper($nodeType) . '_ONLINE_USER', $nodeId),
count($data),
3600
);
Cache::put(
CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_PUSH_AT', $nodeId),
time(),
3600
);
$userService = new UserService();
$userService->trafficFetch($node, $nodeType, $data);
}
}
// handle alive data
$alive = $request->input('alive');
if (is_array($alive) && !empty($alive)) {
UserAliveSyncJob::dispatch($alive, $nodeType, $nodeId);
}
// handle active connections
$online = $request->input('online');
if (is_array($online) && !empty($online)) {
$cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3);
foreach ($online as $uid => $conn) {
$cacheKey = CacheKey::get("USER_ONLINE_CONN_{$nodeType}_{$nodeId}", $uid);
Cache::put($cacheKey, (int) $conn, $cacheTime);
}
}
// handle node status
$status = $request->input('status');
if (is_array($status) && !empty($status)) {
$statusData = [
'cpu' => (float) ($status['cpu'] ?? 0),
'mem' => [
'total' => (int) ($status['mem']['total'] ?? 0),
'used' => (int) ($status['mem']['used'] ?? 0),
],
'swap' => [
'total' => (int) ($status['swap']['total'] ?? 0),
'used' => (int) ($status['swap']['used'] ?? 0),
],
'disk' => [
'total' => (int) ($status['disk']['total'] ?? 0),
'used' => (int) ($status['disk']['used'] ?? 0),
],
'updated_at' => now()->timestamp,
'kernel_status' => $status['kernel_status'] ?? null,
];
$cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3);
cache([
CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LOAD_STATUS', $nodeId) => $statusData,
CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_LOAD_AT', $nodeId) => now()->timestamp,
], $cacheTime);
}
// handle node metrics (Metrics)
$metrics = $request->input('metrics');
if (is_array($metrics) && !empty($metrics)) {
ServerService::updateMetrics($node, $metrics);
}
return response()->json(['data' => true]);
}
}
+2
View File
@@ -37,6 +37,7 @@ class Kernel extends HttpKernel
// \Illuminate\View\Middleware\ShareErrorsFromSession::class, // \Illuminate\View\Middleware\ShareErrorsFromSession::class,
// \App\Http\Middleware\VerifyCsrfToken::class, // \App\Http\Middleware\VerifyCsrfToken::class,
// \Illuminate\Routing\Middleware\SubstituteBindings::class, // \Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\ApplyRuntimeSettings::class,
], ],
'api' => [ 'api' => [
@@ -46,6 +47,7 @@ class Kernel extends HttpKernel
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
// \Illuminate\Routing\Middleware\ThrottleRequests::class . ':api', // \Illuminate\Routing\Middleware\ThrottleRequests::class . ':api',
// \Illuminate\Routing\Middleware\SubstituteBindings::class, // \Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\ApplyRuntimeSettings::class,
\App\Http\Middleware\ForceJson::class, \App\Http\Middleware\ForceJson::class,
\App\Http\Middleware\Language::class, \App\Http\Middleware\Language::class,
'bindings', 'bindings',
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
class ApplyRuntimeSettings
{
public function handle(Request $request, Closure $next)
{
$appUrl = admin_setting('app_url');
if (is_string($appUrl) && $appUrl !== '') {
URL::forceRootUrl($appUrl);
}
if ((bool) admin_setting('force_https', false)) {
URL::forceScheme('https');
}
return $next($request);
}
}
+48 -12
View File
@@ -2,23 +2,59 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\AdminAuditLog;
use Closure; use Closure;
class RequestLog class RequestLog
{ {
/** private const SENSITIVE_KEYS = ['password', 'token', 'secret', 'key', 'api_key'];
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
if ($request->method() === 'POST') { if ($request->method() !== 'POST') {
$path = $request->path(); return $next($request);
info("POST {$path}"); }
};
return $next($request); $response = $next($request);
try {
$admin = $request->user();
if (!$admin || !$admin->is_admin) {
return $response;
}
$action = $this->resolveAction($request->path());
$data = collect($request->all())->except(self::SENSITIVE_KEYS)->toArray();
AdminAuditLog::insert([
'admin_id' => $admin->id,
'action' => $action,
'method' => $request->method(),
'uri' => $request->getRequestUri(),
'request_data' => json_encode($data, JSON_UNESCAPED_UNICODE),
'ip' => $request->getClientIp(),
'created_at' => time(),
'updated_at' => time(),
]);
} catch (\Throwable $e) {
\Log::warning('Audit log write failed: ' . $e->getMessage());
}
return $response;
}
private function resolveAction(string $path): string
{
// api/v2/{secure_path}/user/update → user.update
$path = preg_replace('#^api/v[12]/[^/]+/#', '', $path);
// gift-card/create-template → gift_card.create_template
$path = str_replace('-', '_', $path);
// user/update → user.update, server/manage/sort → server_manage.sort
$segments = explode('/', $path);
$method = array_pop($segments);
$resource = implode('_', $segments);
return $resource . '.' . $method;
} }
} }
+5
View File
@@ -35,6 +35,7 @@ class ConfigSave extends FormRequest
'tos_url' => 'nullable|url', 'tos_url' => 'nullable|url',
'currency' => '', 'currency' => '',
'currency_symbol' => '', 'currency_symbol' => '',
'ticket_must_wait_reply' => '',
// subscribe // subscribe
'plan_change_enable' => '', 'plan_change_enable' => '',
'reset_traffic_method' => 'in:0,1,2,3,4', 'reset_traffic_method' => 'in:0,1,2,3,4',
@@ -50,6 +51,8 @@ class ConfigSave extends FormRequest
'server_pull_interval' => 'integer', 'server_pull_interval' => 'integer',
'server_push_interval' => 'integer', 'server_push_interval' => 'integer',
'device_limit_mode' => 'integer', 'device_limit_mode' => 'integer',
'server_ws_enable' => 'boolean',
'server_ws_url' => 'nullable|url',
// frontend // frontend
'frontend_theme' => '', 'frontend_theme' => '',
'frontend_theme_sidebar' => 'nullable|in:dark,light', 'frontend_theme_sidebar' => 'nullable|in:dark,light',
@@ -68,6 +71,7 @@ class ConfigSave extends FormRequest
// telegram // telegram
'telegram_bot_enable' => '', 'telegram_bot_enable' => '',
'telegram_bot_token' => '', 'telegram_bot_token' => '',
'telegram_webhook_url' => 'nullable|url',
'telegram_discuss_id' => '', 'telegram_discuss_id' => '',
'telegram_channel_id' => '', 'telegram_channel_id' => '',
'telegram_discuss_link' => 'nullable|url', 'telegram_discuss_link' => 'nullable|url',
@@ -128,6 +132,7 @@ class ConfigSave extends FormRequest
'subscribe_url.url' => '订阅URL格式不正确,必须携带http(s)://', 'subscribe_url.url' => '订阅URL格式不正确,必须携带http(s)://',
'server_token.min' => '通讯密钥长度必须大于16位', 'server_token.min' => '通讯密钥长度必须大于16位',
'tos_url.url' => '服务条款URL格式不正确,必须携带http(s)://', 'tos_url.url' => '服务条款URL格式不正确,必须携带http(s)://',
'telegram_webhook_url.url' => 'Telegram Webhook地址格式不正确,必须携带http(s)://',
'telegram_discuss_link.url' => 'Telegram群组地址必须为URL格式,必须携带http(s)://', 'telegram_discuss_link.url' => 'Telegram群组地址必须为URL格式,必须携带http(s)://',
'logo.url' => 'LOGO URL格式不正确,必须携带https(s)://', 'logo.url' => 'LOGO URL格式不正确,必须携带https(s)://',
'secure_path.min' => '后台路径长度最小为8位', 'secure_path.min' => '后台路径长度最小为8位',
+60 -4
View File
@@ -8,6 +8,23 @@ use Illuminate\Foundation\Http\FormRequest;
class ServerSave extends FormRequest class ServerSave extends FormRequest
{ {
private const UTLS_RULES = [
'utls.enabled' => 'nullable|boolean',
'utls.fingerprint' => 'nullable|string',
];
private const MULTIPLEX_RULES = [
'multiplex.enabled' => 'nullable|boolean',
'multiplex.protocol' => 'nullable|string',
'multiplex.max_connections' => 'nullable|integer',
'multiplex.min_streams' => 'nullable|integer',
'multiplex.max_streams' => 'nullable|integer',
'multiplex.padding' => 'nullable|boolean',
'multiplex.brutal.enabled' => 'nullable|boolean',
'multiplex.brutal.up_mbps' => 'nullable|integer',
'multiplex.brutal.down_mbps' => 'nullable|integer',
];
private const PROTOCOL_RULES = [ private const PROTOCOL_RULES = [
'shadowsocks' => [ 'shadowsocks' => [
'cipher' => 'required|string', 'cipher' => 'required|string',
@@ -67,8 +84,8 @@ class ServerSave extends FormRequest
'tls_settings' => 'nullable|array', 'tls_settings' => 'nullable|array',
], ],
'mieru' => [ 'mieru' => [
'transport' => 'required|string', 'transport' => 'required|string|in:TCP,UDP',
'multiplexing' => 'required|string', 'traffic_pattern' => 'string'
], ],
'anytls' => [ 'anytls' => [
'tls' => 'nullable|array', 'tls' => 'nullable|array',
@@ -97,6 +114,9 @@ class ServerSave extends FormRequest
'rate' => 'required|numeric', 'rate' => 'required|numeric',
'rate_time_enable' => 'nullable|boolean', 'rate_time_enable' => 'nullable|boolean',
'rate_time_ranges' => 'nullable|array', 'rate_time_ranges' => 'nullable|array',
'custom_outbounds' => 'nullable|array',
'custom_routes' => 'nullable|array',
'cert_config' => 'nullable|array',
'rate_time_ranges.*.start' => 'required_with:rate_time_ranges|string|date_format:H:i', 'rate_time_ranges.*.start' => 'required_with:rate_time_ranges|string|date_format:H:i',
'rate_time_ranges.*.end' => 'required_with:rate_time_ranges|string|date_format:H:i', 'rate_time_ranges.*.end' => 'required_with:rate_time_ranges|string|date_format:H:i',
'rate_time_ranges.*.rate' => 'required_with:rate_time_ranges|numeric|min:0', 'rate_time_ranges.*.rate' => 'required_with:rate_time_ranges|numeric|min:0',
@@ -109,13 +129,45 @@ class ServerSave extends FormRequest
$type = $this->input('type'); $type = $this->input('type');
$rules = $this->getBaseRules(); $rules = $this->getBaseRules();
foreach (self::PROTOCOL_RULES[$type] ?? [] as $field => $rule) { $protocolRules = self::PROTOCOL_RULES[$type] ?? [];
if (in_array($type, ['vmess', 'vless', 'trojan', 'mieru'])) {
$protocolRules = array_merge($protocolRules, self::MULTIPLEX_RULES, self::UTLS_RULES);
}
foreach ($protocolRules as $field => $rule) {
$rules['protocol_settings.' . $field] = $rule; $rules['protocol_settings.' . $field] = $rule;
} }
return $rules; return $rules;
} }
public function attributes(): array
{
return [
'protocol_settings.cipher' => '加密方式',
'protocol_settings.obfs' => '混淆类型',
'protocol_settings.network' => '传输协议',
'protocol_settings.port_range' => '端口范围',
'protocol_settings.traffic_pattern' => 'Traffic Pattern',
'protocol_settings.transport' => '传输方式',
'protocol_settings.version' => '协议版本',
'protocol_settings.password' => '密码',
'protocol_settings.handshake.server' => '握手服务器',
'protocol_settings.handshake.server_port' => '握手端口',
'protocol_settings.multiplex.enabled' => '多路复用',
'protocol_settings.multiplex.protocol' => '复用协议',
'protocol_settings.multiplex.max_connections' => '最大连接数',
'protocol_settings.multiplex.min_streams' => '最小流数',
'protocol_settings.multiplex.max_streams' => '最大流数',
'protocol_settings.multiplex.padding' => '复用填充',
'protocol_settings.multiplex.brutal.enabled' => 'Brutal加速',
'protocol_settings.multiplex.brutal.up_mbps' => 'Brutal上行速率',
'protocol_settings.multiplex.brutal.down_mbps' => 'Brutal下行速率',
'protocol_settings.utls.enabled' => 'uTLS',
'protocol_settings.utls.fingerprint' => 'uTLS指纹',
];
}
public function messages() public function messages()
{ {
return [ return [
@@ -136,7 +188,11 @@ class ServerSave extends FormRequest
'networkSettings.array' => '传输协议配置有误', 'networkSettings.array' => '传输协议配置有误',
'ruleSettings.array' => '规则配置有误', 'ruleSettings.array' => '规则配置有误',
'tlsSettings.array' => 'tls配置有误', 'tlsSettings.array' => 'tls配置有误',
'dnsSettings.array' => 'dns配置有误' 'dnsSettings.array' => 'dns配置有误',
'protocol_settings.*.required' => ':attribute 不能为空',
'protocol_settings.*.string' => ':attribute 必须是字符串',
'protocol_settings.*.integer' => ':attribute 必须是整数',
'protocol_settings.*.in' => ':attribute 的值不合法',
]; ];
} }
} }
+1 -3
View File
@@ -218,10 +218,8 @@ class AdminRoute
$router->get('/getQueueStats', [SystemController::class, 'getQueueStats']); $router->get('/getQueueStats', [SystemController::class, 'getQueueStats']);
$router->get('/getQueueWorkload', [SystemController::class, 'getQueueWorkload']); $router->get('/getQueueWorkload', [SystemController::class, 'getQueueWorkload']);
$router->get('/getQueueMasters', '\\Laravel\\Horizon\\Http\\Controllers\\MasterSupervisorController@index'); $router->get('/getQueueMasters', '\\Laravel\\Horizon\\Http\\Controllers\\MasterSupervisorController@index');
$router->get('/getSystemLog', [SystemController::class, 'getSystemLog']);
$router->get('/getHorizonFailedJobs', [SystemController::class, 'getHorizonFailedJobs']); $router->get('/getHorizonFailedJobs', [SystemController::class, 'getHorizonFailedJobs']);
$router->post('/clearSystemLog', [SystemController::class, 'clearSystemLog']); $router->any('/getAuditLog', [SystemController::class, 'getAuditLog']);
$router->get('/getLogClearStats', [SystemController::class, 'getLogClearStats']);
}); });
// Update // Update
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Routes\V2;
use App\Http\Controllers\V2\Client\AppController;
use Illuminate\Contracts\Routing\Registrar;
class ClientRoute
{
public function map(Registrar $router)
{
$router->group([
'prefix' => 'client',
'middleware' => 'client'
], function ($router) {
// App
$router->get('/app/getConfig', [AppController::class, 'getConfig']);
$router->get('/app/getVersion', [AppController::class, 'getVersion']);
});
}
}
+3
View File
@@ -4,6 +4,7 @@ namespace App\Http\Routes\V2;
use App\Http\Controllers\V1\Server\ShadowsocksTidalabController; use App\Http\Controllers\V1\Server\ShadowsocksTidalabController;
use App\Http\Controllers\V1\Server\TrojanTidalabController; use App\Http\Controllers\V1\Server\TrojanTidalabController;
use App\Http\Controllers\V1\Server\UniProxyController; use App\Http\Controllers\V1\Server\UniProxyController;
use App\Http\Controllers\V2\Server\ServerController;
use Illuminate\Contracts\Routing\Registrar; use Illuminate\Contracts\Routing\Registrar;
class ServerRoute class ServerRoute
@@ -15,6 +16,8 @@ class ServerRoute
'prefix' => 'server', 'prefix' => 'server',
'middleware' => 'server' 'middleware' => 'server'
], function ($route) { ], function ($route) {
$route->post('handshake', [ServerController::class, 'handshake']);
$route->post('report', [ServerController::class, 'report']);
$route->get('config', [UniProxyController::class, 'config']); $route->get('config', [UniProxyController::class, 'config']);
$route->get('user', [UniProxyController::class, 'user']); $route->get('user', [UniProxyController::class, 'user']);
$route->post('push', [UniProxyController::class, 'push']); $route->post('push', [UniProxyController::class, 'push']);
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Jobs;
use App\Models\User;
use App\Services\NodeSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class NodeUserSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 2;
public $timeout = 10;
public function __construct(
private readonly int $userId,
private readonly string $action,
private readonly ?int $oldGroupId = null
) {
$this->onQueue('node_sync');
}
public function handle(): void
{
$user = User::find($this->userId);
if ($this->action === 'updated' || $this->action === 'created') {
if ($this->oldGroupId) {
NodeSyncService::notifyUserRemovedFromGroup($this->userId, $this->oldGroupId);
}
if ($user) {
NodeSyncService::notifyUserChanged($user);
}
} elseif ($this->action === 'deleted') {
if ($this->oldGroupId) {
NodeSyncService::notifyUserRemovedFromGroup($this->userId, $this->oldGroupId);
}
}
}
}
@@ -12,7 +12,7 @@ use Illuminate\Support\Facades\Cache;
use App\Services\UserOnlineService; use App\Services\UserOnlineService;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class UpdateAliveDataJob implements ShouldQueue class UserAliveSyncJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -25,7 +25,7 @@ class UpdateAliveDataJob implements ShouldQueue
private readonly string $nodeType, private readonly string $nodeType,
private readonly int $nodeId private readonly int $nodeId
) { ) {
$this->onQueue('online_sync'); $this->onQueue('user_alive_sync');
} }
public function handle(): void public function handle(): void
@@ -97,7 +97,7 @@ class UpdateAliveDataJob implements ShouldQueue
} }
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('UpdateAliveDataJob failed', [ Log::error('UserAliveSyncJob failed', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
$this->fail($e); $this->fail($e);
-11
View File
@@ -1,11 +0,0 @@
<?php
namespace App\Logging;
class MysqlLogger
{
public function __invoke(array $config){
return tap(new \Monolog\Logger('mysql'), function ($logger) {
$logger->pushHandler(new MysqlLoggerHandler());
});
}
}
-45
View File
@@ -1,45 +0,0 @@
<?php
namespace App\Logging;
use Illuminate\Support\Facades\Log;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Logger;
use App\Models\Log as LogModel;
use Monolog\LogRecord;
class MysqlLoggerHandler extends AbstractProcessingHandler
{
public function __construct($level = Logger::DEBUG, bool $bubble = true)
{
parent::__construct($level, $bubble);
}
protected function write(LogRecord $record): void
{
$record = $record->toArray();
try {
if (isset($record['context']['exception']) && is_object($record['context']['exception'])) {
$record['context']['exception'] = (array)$record['context']['exception'];
}
$record['request_data'] = request()->all();
$log = [
'title' => $record['message'],
'level' => $record['level_name'],
'host' => $record['extra']['request_host'] ?? request()->getSchemeAndHttpHost(),
'uri' => $record['extra']['request_uri'] ?? request()->getRequestUri(),
'method' => $record['extra']['request_method'] ?? request()->getMethod(),
'ip' => request()->getClientIp(),
'data' => json_encode($record['request_data']),
'context' => json_encode($record['context']),
'created_at' => $record['datetime']->getTimestamp(),
'updated_at' => $record['datetime']->getTimestamp(),
];
LogModel::insert($log);
} catch (\Exception $e) {
// Log::channel('daily')->error($e->getMessage().$e->getFile().$e->getTraceAsString());
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AdminAuditLog extends Model
{
protected $table = 'v2_admin_audit_log';
protected $dateFormat = 'U';
protected $guarded = ['id'];
protected $casts = [
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
];
public function admin()
{
return $this->belongsTo(User::class, 'admin_id');
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Log extends Model
{
use \App\Scope\FilterScope;
protected $table = 'v2_log';
protected $dateFormat = 'U';
protected $guarded = ['id'];
protected $casts = [
'created_at' => 'timestamp',
'updated_at' => 'timestamp'
];
}
+79 -8
View File
@@ -41,6 +41,8 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
* @property-read int|null $last_check_at 最后检查时间(Unix时间戳) * @property-read int|null $last_check_at 最后检查时间(Unix时间戳)
* @property-read int|null $last_push_at 最后推送时间(Unix时间戳) * @property-read int|null $last_push_at 最后推送时间(Unix时间戳)
* @property-read int $online 在线用户数 * @property-read int $online 在线用户数
* @property-read int $online_conn 在线连接数
* @property-read array|null $metrics 节点指标指标
* @property-read int $is_online 是否在线(1在线 0离线) * @property-read int $is_online 是否在线(1在线 0离线)
* @property-read string $available_status 可用状态描述 * @property-read string $available_status 可用状态描述
* @property-read string $cache_key 缓存键 * @property-read string $cache_key 缓存键
@@ -112,6 +114,9 @@ class Server extends Model
'route_ids' => 'array', 'route_ids' => 'array',
'tags' => 'array', 'tags' => 'array',
'protocol_settings' => 'array', 'protocol_settings' => 'array',
'custom_outbounds' => 'array',
'custom_routes' => 'array',
'cert_config' => 'array',
'last_check_at' => 'integer', 'last_check_at' => 'integer',
'last_push_at' => 'integer', 'last_push_at' => 'integer',
'show' => 'boolean', 'show' => 'boolean',
@@ -121,19 +126,55 @@ class Server extends Model
'rate_time_enable' => 'boolean', 'rate_time_enable' => 'boolean',
]; ];
private const MULTIPLEX_CONFIGURATION = [
'multiplex' => [
'type' => 'object',
'fields' => [
'enabled' => ['type' => 'boolean', 'default' => false],
'protocol' => ['type' => 'string', 'default' => 'yamux'],
'max_connections' => ['type' => 'integer', 'default' => null],
// 'min_streams' => ['type' => 'integer', 'default' => null],
// 'max_streams' => ['type' => 'integer', 'default' => null],
'padding' => ['type' => 'boolean', 'default' => false],
'brutal' => [
'type' => 'object',
'fields' => [
'enabled' => ['type' => 'boolean', 'default' => false],
'up_mbps' => ['type' => 'integer', 'default' => null],
'down_mbps' => ['type' => 'integer', 'default' => null],
]
]
]
]
];
private const UTLS_CONFIGURATION = [
'utls' => [
'type' => 'object',
'fields' => [
'enabled' => ['type' => 'boolean', 'default' => false],
'fingerprint' => ['type' => 'string', 'default' => 'chrome'],
]
]
];
private const PROTOCOL_CONFIGURATIONS = [ private const PROTOCOL_CONFIGURATIONS = [
self::TYPE_TROJAN => [ self::TYPE_TROJAN => [
'allow_insecure' => ['type' => 'boolean', 'default' => false],
'server_name' => ['type' => 'string', 'default' => null],
'network' => ['type' => 'string', 'default' => null], 'network' => ['type' => 'string', 'default' => null],
'network_settings' => ['type' => 'array', 'default' => null] 'network_settings' => ['type' => 'array', 'default' => null],
'server_name' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false],
...self::MULTIPLEX_CONFIGURATION,
...self::UTLS_CONFIGURATION
], ],
self::TYPE_VMESS => [ self::TYPE_VMESS => [
'tls' => ['type' => 'integer', 'default' => 0], 'tls' => ['type' => 'integer', 'default' => 0],
'network' => ['type' => 'string', 'default' => null], 'network' => ['type' => 'string', 'default' => null],
'rules' => ['type' => 'array', 'default' => null], 'rules' => ['type' => 'array', 'default' => null],
'network_settings' => ['type' => 'array', 'default' => null], 'network_settings' => ['type' => 'array', 'default' => null],
'tls_settings' => ['type' => 'array', 'default' => null] 'tls_settings' => ['type' => 'array', 'default' => null],
...self::MULTIPLEX_CONFIGURATION,
...self::UTLS_CONFIGURATION
], ],
self::TYPE_VLESS => [ self::TYPE_VLESS => [
'tls' => ['type' => 'integer', 'default' => 0], 'tls' => ['type' => 'integer', 'default' => 0],
@@ -151,7 +192,9 @@ class Server extends Model
'private_key' => ['type' => 'string', 'default' => null], 'private_key' => ['type' => 'string', 'default' => null],
'short_id' => ['type' => 'string', 'default' => null] 'short_id' => ['type' => 'string', 'default' => null]
] ]
] ],
...self::MULTIPLEX_CONFIGURATION,
...self::UTLS_CONFIGURATION
], ],
self::TYPE_SHADOWSOCKS => [ self::TYPE_SHADOWSOCKS => [
'cipher' => ['type' => 'string', 'default' => null], 'cipher' => ['type' => 'string', 'default' => null],
@@ -240,13 +283,15 @@ class Server extends Model
'tls_settings' => [ 'tls_settings' => [
'type' => 'object', 'type' => 'object',
'fields' => [ 'fields' => [
'allow_insecure' => ['type' => 'boolean', 'default' => false] 'allow_insecure' => ['type' => 'boolean', 'default' => false],
'server_name' => ['type' => 'string', 'default' => null]
] ]
] ]
], ],
self::TYPE_MIERU => [ self::TYPE_MIERU => [
'transport' => ['type' => 'string', 'default' => 'tcp'], 'transport' => ['type' => 'string', 'default' => 'TCP'],
'multiplexing' => ['type' => 'string', 'default' => 'MULTIPLEXING_LOW'] 'traffic_pattern' => ['type' => 'string', 'default' => ''],
...self::MULTIPLEX_CONFIGURATION,
] ]
]; ];
@@ -440,6 +485,32 @@ class Server extends Model
); );
} }
/**
* 指标指标访问器
*/
protected function metrics(): Attribute
{
return Attribute::make(
get: function () {
$type = strtoupper($this->type);
$serverId = $this->parent_id ?: $this->id;
return Cache::get(CacheKey::get("SERVER_{$type}_METRICS", $serverId));
}
);
}
/**
* 在线连接数访问器
*/
protected function onlineConn(): Attribute
{
return Attribute::make(
get: function () {
return $this->metrics['active_connections'] ?? 0;
}
);
}
/** /**
* 负载状态访问器 * 负载状态访问器
*/ */
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class SubscribeTemplate extends Model
{
protected $table = 'v2_subscribe_templates';
protected $guarded = [];
protected $casts = [
'name' => 'string',
'content' => 'string',
];
private static string $cachePrefix = 'subscribe_template:';
public static function getContent(string $name): ?string
{
$cacheKey = self::$cachePrefix . $name;
return Cache::store('redis')->remember($cacheKey, 3600, function () use ($name) {
return self::where('name', $name)->value('content');
});
}
public static function setContent(string $name, ?string $content): void
{
self::updateOrCreate(
['name' => $name],
['content' => $content]
);
Cache::store('redis')->forget(self::$cachePrefix . $name);
}
public static function getAllContents(): array
{
return self::pluck('content', 'name')->toArray();
}
public static function flushCache(string $name): void
{
Cache::store('redis')->forget(self::$cachePrefix . $name);
}
}
+8
View File
@@ -147,6 +147,14 @@ class User extends Authenticatable
$this->plan_id !== null; $this->plan_id !== null;
} }
/**
* 检查用户是否可用节点流量且充足
*/
public function isAvailable(): bool
{
return $this->isActive() && $this->getRemainingTraffic() > 0;
}
/** /**
* 检查是否需要重置流量 * 检查是否需要重置流量
*/ */
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Observers;
use App\Models\Plan;
use App\Models\User;
use App\Services\TrafficResetService;
class PlanObserver
{
/**
* reset user next_reset_at
*/
public function updated(Plan $plan): void
{
if (!$plan->isDirty('reset_traffic_method')) {
return;
}
$trafficResetService = app(TrafficResetService::class);
User::where('plan_id', $plan->id)
->where('banned', 0)
->where(function ($query) {
$query->where('expired_at', '>', time())
->orWhereNull('expired_at');
})
->lazyById(500)
->each(function (User $user) use ($trafficResetService) {
$nextResetTime = $trafficResetService->calculateNextResetTime($user);
$user->update([
'next_reset_at' => $nextResetTime?->timestamp,
]);
});
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Observers;
use App\Models\Server;
use App\Services\NodeSyncService;
class ServerObserver
{
public function updated(Server $server): void
{
if (
$server->isDirty([
'group_ids',
])
) {
NodeSyncService::notifyUsersUpdatedByGroup($server->id);
} else if (
$server->isDirty([
'server_port',
'protocol_settings',
'type',
'route_ids',
'custom_outbounds',
'custom_routes',
'cert_config',
])
) {
NodeSyncService::notifyConfigUpdated($server->id);
}
}
public function deleted(Server $server): void
{
NodeSyncService::notifyConfigUpdated($server->id);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Observers;
use App\Models\Server;
use App\Models\ServerRoute;
use App\Services\NodeSyncService;
class ServerRouteObserver
{
public function updated(ServerRoute $route): void
{
$this->notifyAffectedNodes($route->id);
}
public function deleted(ServerRoute $route): void
{
$this->notifyAffectedNodes($route->id);
}
private function notifyAffectedNodes(int $routeId): void
{
$servers = Server::where('show', 1)->get()->filter(
fn ($s) => in_array($routeId, $s->route_ids ?? [])
);
foreach ($servers as $server) {
NodeSyncService::notifyConfigUpdated($server->id);
}
}
}
+33 -6
View File
@@ -2,6 +2,7 @@
namespace App\Observers; namespace App\Observers;
use App\Jobs\NodeUserSyncJob;
use App\Models\User; use App\Models\User;
use App\Services\TrafficResetService; use App\Services\TrafficResetService;
@@ -15,12 +16,38 @@ class UserObserver
public function updated(User $user): void public function updated(User $user): void
{ {
if ($user->isDirty(['plan_id', 'expired_at'])) { if ($user->isDirty(['plan_id', 'expired_at'])) {
$user->refresh(); $this->recalculateNextResetAt($user);
User::withoutEvents(function () use ($user) { }
$nextResetTime = $this->trafficResetService->calculateNextResetTime($user);
$user->next_reset_at = $nextResetTime?->timestamp; if ($user->isDirty(['group_id', 'uuid', 'speed_limit', 'device_limit', 'banned', 'expired_at', 'transfer_enable', 'u', 'd', 'plan_id'])) {
$user->save(); $oldGroupId = $user->isDirty('group_id') ? $user->getOriginal('group_id') : null;
}); NodeUserSyncJob::dispatch($user->id, 'updated', $oldGroupId);
} }
} }
public function created(User $user): void
{
$this->recalculateNextResetAt($user);
NodeUserSyncJob::dispatch($user->id, 'created');
}
public function deleted(User $user): void
{
if ($user->group_id) {
NodeUserSyncJob::dispatch($user->id, 'deleted', $user->group_id);
}
}
/**
* 根据当前用户状态重新计算 next_reset_at
*/
private function recalculateNextResetAt(User $user): void
{
$user->refresh();
User::withoutEvents(function () use ($user) {
$nextResetTime = $this->trafficResetService->calculateNextResetTime($user);
$user->next_reset_at = $nextResetTime?->timestamp;
$user->save();
});
}
} }
+1 -3
View File
@@ -27,9 +27,7 @@ class Clash extends AbstractProtocol
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
// 优先从数据库配置中获取模板 // 优先从数据库配置中获取模板
$template = admin_setting('subscribe_template_clash', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $template = subscribe_template('clash');
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
+149 -31
View File
@@ -35,6 +35,7 @@ class ClashMeta extends AbstractProtocol
'grpc' => '0.0.0', 'grpc' => '0.0.0',
'http' => '0.0.0', 'http' => '0.0.0',
'h2' => '0.0.0', 'h2' => '0.0.0',
'httpupgrade' => '0.0.0',
], ],
'strict' => true, 'strict' => true,
], ],
@@ -65,13 +66,7 @@ class ClashMeta extends AbstractProtocol
$user = $this->user; $user = $this->user;
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
$template = admin_setting('subscribe_template_clashmeta', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $template = subscribe_template('clashmeta');
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: (
File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE))
));
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
@@ -199,7 +194,7 @@ class ClashMeta extends AbstractProtocol
->filter() ->filter()
->mapWithKeys(function ($pair) { ->mapWithKeys(function ($pair) {
if (!str_contains($pair, '=')) { if (!str_contains($pair, '=')) {
return []; return [trim($pair) => true];
} }
[$key, $value] = explode('=', $pair, 2); [$key, $value] = explode('=', $pair, 2);
return [trim($key) => trim($value)]; return [trim($key) => trim($value)];
@@ -209,28 +204,42 @@ class ClashMeta extends AbstractProtocol
// 根据插件类型进行字段映射 // 根据插件类型进行字段映射
switch ($plugin) { switch ($plugin) {
case 'obfs': case 'obfs':
$array['plugin-opts'] = [ case 'obfs-local':
'mode' => $parsedOpts['obfs'], $array['plugin'] = 'obfs';
'host' => $parsedOpts['obfs-host'], $array['plugin-opts'] = array_filter([
]; 'mode' => $parsedOpts['obfs'] ?? ($parsedOpts['mode'] ?? 'http'),
'host' => $parsedOpts['obfs-host'] ?? ($parsedOpts['host'] ?? 'www.bing.com'),
// 可选path参数 ]);
if (isset($parsedOpts['path'])) {
$array['plugin-opts']['path'] = $parsedOpts['path'];
}
break; break;
case 'v2ray-plugin': case 'v2ray-plugin':
$array['plugin-opts'] = [ $array['plugin-opts'] = array_filter([
'mode' => $parsedOpts['mode'] ?? 'websocket', 'mode' => $parsedOpts['mode'] ?? 'websocket',
'tls' => isset($parsedOpts['tls']) && $parsedOpts['tls'] == 'true', 'tls' => isset($parsedOpts['tls']) || isset($parsedOpts['server']),
'host' => $parsedOpts['host'] ?? '', 'host' => $parsedOpts['host'] ?? null,
'path' => $parsedOpts['path'] ?? '/', 'path' => $parsedOpts['path'] ?? '/',
]; 'mux' => isset($parsedOpts['mux']) ? true : null,
'headers' => isset($parsedOpts['host']) ? ['Host' => $parsedOpts['host']] : null
], fn($v) => $v !== null);
break;
case 'shadow-tls':
$array['plugin-opts'] = array_filter([
'host' => $parsedOpts['host'] ?? null,
'password' => $parsedOpts['password'] ?? null,
'version' => isset($parsedOpts['version']) ? (int) $parsedOpts['version'] : 2
], fn($v) => $v !== null);
break;
case 'restls':
$array['plugin-opts'] = array_filter([
'host' => $parsedOpts['host'] ?? null,
'password' => $parsedOpts['password'] ?? null,
'restls-script' => $parsedOpts['restls-script'] ?? '123'
], fn($v) => $v !== null);
break; break;
default: default:
// 对于其他插件,直接使用解析出的键值对
$array['plugin-opts'] = $parsedOpts; $array['plugin-opts'] = $parsedOpts;
} }
} }
@@ -252,19 +261,24 @@ class ClashMeta extends AbstractProtocol
]; ];
if (data_get($protocol_settings, 'tls')) { if (data_get($protocol_settings, 'tls')) {
$array['tls'] = true; $array['tls'] = (bool) data_get($protocol_settings, 'tls');
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
$array['servername'] = data_get($protocol_settings, 'tls_settings.server_name'); $array['servername'] = data_get($protocol_settings, 'tls_settings.server_name');
} }
self::appendUtls($array, $protocol_settings);
self::appendMultiplex($array, $protocol_settings);
switch (data_get($protocol_settings, 'network')) { switch (data_get($protocol_settings, 'network')) {
case 'tcp': case 'tcp':
$array['network'] = data_get($protocol_settings, 'network_settings.header.type', 'tcp'); $array['network'] = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') { if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') {
if ($httpOpts = array_filter([ if (
'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), $httpOpts = array_filter([
'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'),
])) { 'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/'])
])
) {
$array['http-opts'] = $httpOpts; $array['http-opts'] = $httpOpts;
} }
} }
@@ -281,6 +295,22 @@ class ClashMeta extends AbstractProtocol
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$array['grpc-opts']['grpc-service-name'] = $serviceName; $array['grpc-opts']['grpc-service-name'] = $serviceName;
break; break;
case 'h2':
$array['network'] = 'h2';
$array['h2-opts'] = [];
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['h2-opts']['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host'))
$array['h2-opts']['host'] = is_array($host) ? $host : [$host];
break;
case 'httpupgrade':
$array['network'] = 'ws';
$array['ws-opts'] = ['v2ray-http-upgrade' => true];
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['ws-opts']['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host'))
$array['ws-opts']['headers'] = ['Host' => $host];
break;
default: default:
break; break;
} }
@@ -311,6 +341,7 @@ class ClashMeta extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['servername'] = $serverName; $array['servername'] = $serverName;
} }
self::appendUtls($array, $protocol_settings);
break; break;
case 2: case 2:
$array['tls'] = true; $array['tls'] = true;
@@ -320,13 +351,28 @@ class ClashMeta extends AbstractProtocol
'public-key' => data_get($protocol_settings, 'reality_settings.public_key'), 'public-key' => data_get($protocol_settings, 'reality_settings.public_key'),
'short-id' => data_get($protocol_settings, 'reality_settings.short_id') 'short-id' => data_get($protocol_settings, 'reality_settings.short_id')
]; ];
$array['client-fingerprint'] = Helper::getRandFingerprint(); self::appendUtls($array, $protocol_settings);
break; break;
default: default:
break; break;
} }
switch (data_get($protocol_settings, 'network')) { switch (data_get($protocol_settings, 'network')) {
case 'tcp':
$array['network'] = 'tcp';
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'none');
if ($headerType === 'http') {
$array['network'] = 'http';
if (
$httpOpts = array_filter([
'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'),
'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/'])
])
) {
$array['http-opts'] = $httpOpts;
}
}
break;
case 'ws': case 'ws':
$array['network'] = 'ws'; $array['network'] = 'ws';
if ($path = data_get($protocol_settings, 'network_settings.path')) if ($path = data_get($protocol_settings, 'network_settings.path'))
@@ -339,10 +385,28 @@ class ClashMeta extends AbstractProtocol
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$array['grpc-opts']['grpc-service-name'] = $serviceName; $array['grpc-opts']['grpc-service-name'] = $serviceName;
break; break;
case 'h2':
$array['network'] = 'h2';
$array['h2-opts'] = [];
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['h2-opts']['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host'))
$array['h2-opts']['host'] = is_array($host) ? $host : [$host];
break;
case 'httpupgrade':
$array['network'] = 'ws';
$array['ws-opts'] = ['v2ray-http-upgrade' => true];
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['ws-opts']['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host'))
$array['ws-opts']['headers'] = ['Host' => $host];
break;
default: default:
break; break;
} }
self::appendMultiplex($array, $protocol_settings);
return $array; return $array;
} }
@@ -362,6 +426,9 @@ class ClashMeta extends AbstractProtocol
$array['sni'] = $serverName; $array['sni'] = $serverName;
} }
self::appendUtls($array, $protocol_settings);
self::appendMultiplex($array, $protocol_settings);
switch (data_get($protocol_settings, 'network')) { switch (data_get($protocol_settings, 'network')) {
case 'tcp': case 'tcp':
$array['network'] = 'tcp'; $array['network'] = 'tcp';
@@ -378,6 +445,22 @@ class ClashMeta extends AbstractProtocol
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$array['grpc-opts']['grpc-service-name'] = $serviceName; $array['grpc-opts']['grpc-service-name'] = $serviceName;
break; break;
case 'h2':
$array['network'] = 'h2';
$array['h2-opts'] = [];
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['h2-opts']['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host'))
$array['h2-opts']['host'] = is_array($host) ? $host : [$host];
break;
case 'httpupgrade':
$array['network'] = 'ws';
$array['ws-opts'] = ['v2ray-http-upgrade' => true];
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['ws-opts']['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host'))
$array['ws-opts']['headers'] = ['Host' => $host];
break;
default: default:
$array['network'] = 'tcp'; $array['network'] = 'tcp';
break; break;
@@ -401,6 +484,9 @@ class ClashMeta extends AbstractProtocol
if (isset($server['ports'])) { if (isset($server['ports'])) {
$array['ports'] = $server['ports']; $array['ports'] = $server['ports'];
} }
if ($hopInterval = data_get($protocol_settings, 'hop_interval')) {
$array['hop-interval'] = (int) $hopInterval;
}
switch (data_get($protocol_settings, 'version')) { switch (data_get($protocol_settings, 'version')) {
case 1: case 1:
$array['type'] = 'hysteria'; $array['type'] = 'hysteria';
@@ -491,8 +577,7 @@ class ClashMeta extends AbstractProtocol
'port' => $server['port'], 'port' => $server['port'],
'username' => $password, 'username' => $password,
'password' => $password, 'password' => $password,
'transport' => strtoupper(data_get($protocol_settings, 'transport', 'TCP')), 'transport' => strtoupper(data_get($protocol_settings, 'transport', 'TCP'))
'multiplexing' => data_get($protocol_settings, 'multiplexing', 'MULTIPLEXING_LOW')
]; ];
// 如果配置了端口范围 // 如果配置了端口范围
@@ -566,4 +651,37 @@ class ClashMeta extends AbstractProtocol
return false; return false;
} }
} }
}
protected static function appendMultiplex(&$array, $protocol_settings)
{
if ($multiplex = data_get($protocol_settings, 'multiplex')) {
if (data_get($multiplex, 'enabled')) {
$array['smux'] = array_filter([
'enabled' => true,
'protocol' => data_get($multiplex, 'protocol', 'yamux'),
'max-connections' => data_get($multiplex, 'max_connections'),
// 'min-streams' => data_get($multiplex, 'min_streams'),
// 'max-streams' => data_get($multiplex, 'max_streams'),
'padding' => data_get($multiplex, 'padding') ? true : null,
]);
if (data_get($multiplex, 'brutal.enabled')) {
$array['smux']['brutal-opts'] = [
'enabled' => true,
'up' => data_get($multiplex, 'brutal.up_mbps'),
'down' => data_get($multiplex, 'brutal.down_mbps'),
];
}
}
}
}
protected static function appendUtls(&$array, $protocol_settings)
{
if ($utls = data_get($protocol_settings, 'utls')) {
if (data_get($utls, 'enabled')) {
$array['client-fingerprint'] = Helper::getTlsFingerprint($utls);
}
}
}
}
+164 -18
View File
@@ -17,7 +17,10 @@ class General extends AbstractProtocol
Server::TYPE_SHADOWSOCKS, Server::TYPE_SHADOWSOCKS,
Server::TYPE_TROJAN, Server::TYPE_TROJAN,
Server::TYPE_HYSTERIA, Server::TYPE_HYSTERIA,
Server::TYPE_ANYTLS,
Server::TYPE_SOCKS, Server::TYPE_SOCKS,
Server::TYPE_TUIC,
Server::TYPE_HTTP,
]; ];
protected $protocolRequirements = [ protected $protocolRequirements = [
@@ -38,7 +41,10 @@ class General extends AbstractProtocol
Server::TYPE_SHADOWSOCKS => self::buildShadowsocks($item['password'], $item), Server::TYPE_SHADOWSOCKS => self::buildShadowsocks($item['password'], $item),
Server::TYPE_TROJAN => self::buildTrojan($item['password'], $item), Server::TYPE_TROJAN => self::buildTrojan($item['password'], $item),
Server::TYPE_HYSTERIA => self::buildHysteria($item['password'], $item), Server::TYPE_HYSTERIA => self::buildHysteria($item['password'], $item),
Server::TYPE_ANYTLS => self::buildAnyTLS($item['password'], $item),
Server::TYPE_SOCKS => self::buildSocks($item['password'], $item), Server::TYPE_SOCKS => self::buildSocks($item['password'], $item),
Server::TYPE_TUIC => self::buildTuic($item['password'], $item),
Server::TYPE_HTTP => self::buildHttp($item['password'], $item),
default => '', default => '',
}; };
} }
@@ -109,6 +115,21 @@ class General extends AbstractProtocol
if ($path = data_get($protocol_settings, 'network_settings.serviceName')) if ($path = data_get($protocol_settings, 'network_settings.serviceName'))
$config['path'] = $path; $config['path'] = $path;
break; break;
case 'h2':
$config['net'] = 'h2';
$config['type'] = '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) ? implode(',', $host) : $host;
break;
case 'httpupgrade':
$config['net'] = 'httpupgrade';
$config['type'] = 'httpupgrade';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$config['path'] = $path;
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
break;
default: default:
break; break;
} }
@@ -133,6 +154,9 @@ class General extends AbstractProtocol
switch ($server['protocol_settings']['tls']) { switch ($server['protocol_settings']['tls']) {
case 1: case 1:
$config['security'] = "tls"; $config['security'] = "tls";
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$config['fp'] = $fp;
}
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config['sni'] = $serverName; $config['sni'] = $serverName;
} }
@@ -144,7 +168,9 @@ class General extends AbstractProtocol
$config['sni'] = data_get($protocol_settings, 'reality_settings.server_name'); $config['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
$config['servername'] = data_get($protocol_settings, 'reality_settings.server_name'); $config['servername'] = data_get($protocol_settings, 'reality_settings.server_name');
$config['spx'] = "/"; $config['spx'] = "/";
$config['fp'] = Helper::getRandFingerprint(); if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$config['fp'] = $fp;
}
break; break;
default: default:
break; break;
@@ -161,6 +187,13 @@ class General extends AbstractProtocol
if ($path = data_get($protocol_settings, 'network_settings.serviceName')) if ($path = data_get($protocol_settings, 'network_settings.serviceName'))
$config['serviceName'] = $path; $config['serviceName'] = $path;
break; break;
case 'h2':
$config['type'] = 'http';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$config['path'] = $path;
if ($h2Host = data_get($protocol_settings, 'network_settings.host'))
$config['host'] = is_array($h2Host) ? implode(',', $h2Host) : $h2Host;
break;
case 'kcp': case 'kcp':
if ($path = data_get($protocol_settings, 'network_settings.seed')) if ($path = data_get($protocol_settings, 'network_settings.seed'))
$config['path'] = $path; $config['path'] = $path;
@@ -210,6 +243,19 @@ class General extends AbstractProtocol
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$array['serviceName'] = $serviceName; $array['serviceName'] = $serviceName;
break; break;
case 'h2':
$array['type'] = 'http';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host'))
$array['host'] = is_array($host) ? implode(',', $host) : $host;
break;
case 'httpupgrade':
$array['type'] = 'httpupgrade';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['path'] = $path;
$array['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
break;
default: default:
break; break;
} }
@@ -225,40 +271,140 @@ class General extends AbstractProtocol
{ {
$protocol_settings = $server['protocol_settings']; $protocol_settings = $server['protocol_settings'];
$params = []; $params = [];
// Return empty if version is not 2 $version = data_get($protocol_settings, 'version', 2);
if ($server['protocol_settings']['version'] !== 2) {
return '';
}
if ($serverName = data_get($protocol_settings, 'tls.server_name')) { if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$params['sni'] = $serverName; $params['sni'] = $serverName;
$params['security'] = 'tls';
} }
$params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure') ? '1' : '0';
if (data_get($protocol_settings, 'obfs.open')) {
$params['obfs'] = 'salamander';
$params['obfs-password'] = data_get($protocol_settings, 'obfs.password');
}
if (isset($server['ports'])) {
$params['mport'] = $server['ports'];
}
$params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure');
$query = http_build_query($params);
$name = rawurlencode($server['name']); $name = rawurlencode($server['name']);
$addr = Helper::wrapIPv6($server['host']); $addr = Helper::wrapIPv6($server['host']);
$uri = "hysteria2://{$password}@{$addr}:{$server['port']}?{$query}#{$name}"; if ($version === 2) {
if (data_get($protocol_settings, 'obfs.open')) {
$params['obfs'] = 'salamander';
$params['obfs-password'] = data_get($protocol_settings, 'obfs.password');
}
if (isset($server['ports'])) {
$params['mport'] = $server['ports'];
}
$query = http_build_query($params);
$uri = "hysteria2://{$password}@{$addr}:{$server['port']}?{$query}#{$name}";
} else {
$params['protocol'] = 'udp';
$params['auth'] = $password;
if ($upMbps = data_get($protocol_settings, 'bandwidth.up'))
$params['upmbps'] = $upMbps;
if ($downMbps = data_get($protocol_settings, 'bandwidth.down'))
$params['downmbps'] = $downMbps;
if ($obfsPassword = data_get($protocol_settings, 'obfs.password'))
$params['obfsParam'] = $obfsPassword;
$query = http_build_query($params);
$uri = "hysteria://{$addr}:{$server['port']}?{$query}#{$name}";
}
$uri .= "\r\n"; $uri .= "\r\n";
return $uri; return $uri;
} }
public static function buildTuic($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$name = rawurlencode($server['name']);
$addr = Helper::wrapIPv6($server['host']);
$port = $server['port'];
$uuid = $password; // v2rayN格式里,uuid和password都是密码部分
$pass = $password;
$queryParams = [];
// 填充sni参数
if ($sni = data_get($protocol_settings, 'tls.server_name')) {
$queryParams['sni'] = $sni;
}
// alpn参数,支持多值时用逗号连接
if ($alpn = data_get($protocol_settings, 'alpn')) {
if (is_array($alpn)) {
$queryParams['alpn'] = implode(',', $alpn);
} else {
$queryParams['alpn'] = $alpn;
}
}
// congestion_controller参数,默认cubic
$congestion = data_get($protocol_settings, 'congestion_control', 'cubic');
$queryParams['congestion_control'] = $congestion;
// udp_relay_mode参数,默认native
$udpRelay = data_get($protocol_settings, 'udp_relay_mode', 'native');
$queryParams['udp-relay-mode'] = $udpRelay;
$query = http_build_query($queryParams);
// 构造完整URI,格式:
// Tuic://uuid:password@host:port?sni=xxx&alpn=xxx&congestion_controller=xxx&udp_relay_mode=xxx#别名
$uri = "tuic://{$uuid}:{$pass}@{$addr}:{$port}";
if (!empty($query)) {
$uri .= "?{$query}";
}
$uri .= "#{$name}\r\n";
return $uri;
}
public static function buildAnyTLS($password, $server)
{
$protocol_settings = $server['protocol_settings'];
$name = rawurlencode($server['name']);
$params = [
'sni' => data_get($protocol_settings, 'tls.server_name'),
'insecure' => data_get($protocol_settings, 'tls.allow_insecure')
];
$query = http_build_query($params);
$uri = "anytls://{$password}@{$server['host']}:{$server['port']}?{$query}#{$name}";
$uri .= "\r\n";
return $uri;
}
public static function buildSocks($password, $server) public static function buildSocks($password, $server)
{ {
$name = rawurlencode($server['name']); $name = rawurlencode($server['name']);
$credentials = base64_encode("{$password}:{$password}"); $credentials = base64_encode("{$password}:{$password}");
return "socks://{$credentials}@{$server['host']}:{$server['port']}#{$name}\r\n"; return "socks://{$credentials}@{$server['host']}:{$server['port']}#{$name}\r\n";
} }
public static function buildHttp($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$name = rawurlencode($server['name']);
$addr = Helper::wrapIPv6($server['host']);
$credentials = base64_encode("{$password}:{$password}");
$params = [];
if (data_get($protocol_settings, 'tls')) {
$params['security'] = 'tls';
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$params['sni'] = $serverName;
}
$params['allowInsecure'] = data_get($protocol_settings, 'tls_settings.allow_insecure') ? '1' : '0';
}
$uri = "http://{$credentials}@{$addr}:{$server['port']}";
if (!empty($params)) {
$uri .= '?' . http_build_query($params);
}
$uri .= "#{$name}\r\n";
return $uri;
}
} }
+74 -51
View File
@@ -14,6 +14,7 @@ class Loon extends AbstractProtocol
Server::TYPE_VMESS, Server::TYPE_VMESS,
Server::TYPE_TROJAN, Server::TYPE_TROJAN,
Server::TYPE_HYSTERIA, Server::TYPE_HYSTERIA,
Server::TYPE_VLESS,
]; ];
protected $protocolRequirements = [ protected $protocolRequirements = [
@@ -42,6 +43,9 @@ class Loon extends AbstractProtocol
if ($item['type'] === Server::TYPE_HYSTERIA) { if ($item['type'] === Server::TYPE_HYSTERIA) {
$uri .= self::buildHysteria($item['password'], $item, $user); $uri .= self::buildHysteria($item['password'], $item, $user);
} }
if ($item['type'] === Server::TYPE_VLESS) {
$uri .= self::buildVless($item['password'], $item);
}
} }
return response($uri) return response($uri)
->header('content-type', 'text/plain') ->header('content-type', 'text/plain')
@@ -176,58 +180,77 @@ class Loon extends AbstractProtocol
return $uri; return $uri;
} }
public static function buildVless($uuid, $server) public static function buildVless($password, $server)
{ {
$protocol_settings = $server['protocol_settings']; $protocol_settings = data_get($server, 'protocol_settings', []);
$config = [
"{$server['name']}=vless",
$server['host'],
$server['port'],
$uuid,
'fast-open=false',
'udp=true',
'alterId=0'
];
switch ((int) data_get($protocol_settings, 'tls')) {
case 1:
$config[] = 'over-tls=true';
$tlsSettings = data_get($protocol_settings, 'tls_settings', []);
if ($tlsSettings) {
$config[] = 'skip-cert-verify=' . (data_get($tlsSettings, 'allow_insecure') ? 'true' : 'false');
if ($serverName = data_get($tlsSettings, 'server_name')) {
$config[] = "tls-name={$serverName}";
}
}
break;
case 2:
return '';
}
$network_settings = data_get($protocol_settings, 'network_settings', []);
switch ((string) data_get($network_settings, 'network')) {
case 'tcp':
$config[] = 'transport=tcp';
if ($headerType = data_get($network_settings, 'header.type')) {
$config = collect($config)->map(function ($item) use ($headerType) {
return $item === 'transport=tcp' ? "transport={$headerType}" : $item;
})->toArray();
}
if ($paths = data_get($network_settings, 'header.request.path')) {
$config[] = 'path=' . $paths[array_rand($paths)];
}
break;
case 'ws':
$config[] = 'transport=ws';
if ($path = data_get($network_settings, 'path')) {
$config[] = "path={$path}";
}
if ($host = data_get($network_settings, 'headers.Host')) { $config = [
$config[] = "host={$host}"; "{$server['name']}=VLESS",
} "{$server['host']}",
break; "{$server['port']}",
} "{$password}",
return implode(',', $config) . "\r\n"; "alterId=0",
} "udp=true"
];
// flow
if ($flow = data_get($protocol_settings, 'flow')) {
$config[] = "flow={$flow}";
}
// TLS/Reality
switch (data_get($protocol_settings, 'tls')) {
case 1:
$config[] = "over-tls=true";
$config[] = "skip-cert-verify=" . (data_get($protocol_settings, 'tls_settings.allow_insecure', false) ? "true" : "false");
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config[] = "sni={$serverName}";
}
break;
case 2:
$config[] = "over-tls=true";
$config[] = "skip-cert-verify=" . (data_get($protocol_settings, 'reality_settings.allow_insecure', false) ? "true" : "false");
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
$config[] = "sni={$serverName}";
}
if ($pubkey = data_get($protocol_settings, 'reality_settings.public_key')) {
$config[] = "public-key={$pubkey}";
}
if ($shortid = data_get($protocol_settings, 'reality_settings.short_id')) {
$config[] = "short-id={$shortid}";
}
break;
default:
$config[] = "over-tls=false";
break;
}
// network
switch (data_get($protocol_settings, 'network')) {
case 'ws':
$config[] = "transport=ws";
if ($path = data_get($protocol_settings, 'network_settings.path')) {
$config[] = "path={$path}";
}
if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) {
$config[] = "host={$host}";
}
break;
case 'grpc':
$config[] = "transport=grpc";
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) {
$config[] = "grpc-service-name={$serviceName}";
}
break;
default:
$config[] = "transport=tcp";
break;
}
$config = array_filter($config);
$uri = implode(',', $config) . "\r\n";
return $uri;
}
public static function buildHysteria($password, $server, $user) public static function buildHysteria($password, $server, $user)
{ {
+10 -3
View File
@@ -165,13 +165,18 @@ class Shadowrocket extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config['peer'] = $serverName; $config['peer'] = $serverName;
} }
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$config['fp'] = $fp;
}
break; break;
case 2: case 2:
$config['tls'] = 1; $config['tls'] = 1;
$config['sni'] = data_get($protocol_settings, 'reality_settings.server_name'); $config['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
$config['pbk'] = data_get($protocol_settings, 'reality_settings.public_key'); $config['pbk'] = data_get($protocol_settings, 'reality_settings.public_key');
$config['sid'] = data_get($protocol_settings, 'reality_settings.short_id'); $config['sid'] = data_get($protocol_settings, 'reality_settings.short_id');
$config['fp'] = Helper::getRandFingerprint(); if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$config['fp'] = $fp;
}
break; break;
default: default:
break; break;
@@ -356,8 +361,10 @@ class Shadowrocket extends AbstractProtocol
} }
public static function buildSocks($password, $server) public static function buildSocks($password, $server)
{ {
$uri = "socks://" . base64_encode("{$password}:{$password}@{$server['host']}:{$server['port']}") . "?method=auto"; $protocol_settings = $server['protocol_settings'];
$name = rawurlencode($server['name']);
$uri = "socks://" . base64_encode("{$password}:{$password}@{$server['host']}:{$server['port']}") . "?method=auto#{$name}";
$uri .= "\r\n"; $uri .= "\r\n";
return $uri; return $uri;
} }
+173 -18
View File
@@ -3,7 +3,6 @@ namespace App\Protocols;
use App\Utils\Helper; use App\Utils\Helper;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use App\Support\AbstractProtocol; use App\Support\AbstractProtocol;
use App\Models\Server; use App\Models\Server;
@@ -54,14 +53,14 @@ class SingBox extends AbstractProtocol
'juicity' => [ 'juicity' => [
'base_version' => '1.7.0' 'base_version' => '1.7.0'
], ],
'shadowtls' => [
'base_version' => '1.6.0'
],
'wireguard' => [ 'wireguard' => [
'base_version' => '1.5.0' 'base_version' => '1.5.0'
], ],
'anytls' => [ 'anytls' => [
'base_version' => '1.12.0' 'base_version' => '1.12.0'
],
'mieru' => [
'base_version' => '1.12.0'
] ]
] ]
]; ];
@@ -72,6 +71,7 @@ class SingBox extends AbstractProtocol
$this->config = $this->loadConfig(); $this->config = $this->loadConfig();
$this->buildOutbounds(); $this->buildOutbounds();
$this->buildRule(); $this->buildRule();
$this->adaptConfigForVersion();
$user = $this->user; $user = $this->user;
return response() return response()
@@ -83,9 +83,7 @@ class SingBox extends AbstractProtocol
protected function loadConfig() protected function loadConfig()
{ {
$jsonData = admin_setting('subscribe_template_singbox', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $jsonData = subscribe_template('singbox');
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
return is_array($jsonData) ? $jsonData : json_decode($jsonData, true); return is_array($jsonData) ? $jsonData : json_decode($jsonData, true);
} }
@@ -135,6 +133,10 @@ class SingBox extends AbstractProtocol
$httpConfig = $this->buildHttp($this->user['uuid'], $item); $httpConfig = $this->buildHttp($this->user['uuid'], $item);
$proxies[] = $httpConfig; $proxies[] = $httpConfig;
} }
if ($item['type'] === Server::TYPE_MIERU) {
$mieruConfig = $this->buildMieru($this->user['uuid'], $item);
$proxies[] = $mieruConfig;
}
} }
foreach ($outbounds as &$outbound) { foreach ($outbounds as &$outbound) {
if (in_array($outbound['type'], ['urltest', 'selector'])) { if (in_array($outbound['type'], ['urltest', 'selector'])) {
@@ -163,6 +165,91 @@ class SingBox extends AbstractProtocol
$this->config['route']['rules'] = $rules; $this->config['route']['rules'] = $rules;
} }
/**
* 根据客户端版本自适应配置格式
*
* sing-box 版本断点:
* - 1.8.0: rule_set 替代 geoip/geosite db, cache_file 替代 clash_api.cache_file
* - 1.10.0: address 数组替代 inet4_address/inet6_address
* - 1.11.0: 移除 endpoint_independent_nat, sniff_override_destination
*/
protected function adaptConfigForVersion(): void
{
$coreVersion = $this->getSingBoxCoreVersion();
if (empty($coreVersion)) {
return;
}
// >= 1.11.0: 移除已废弃字段,避免 "配置已过时" 警告
if (version_compare($coreVersion, '1.11.0', '>=')) {
$this->removeDeprecatedFieldsV111();
}
// < 1.10.0: address 数组 → inet4_address/inet6_address
if (version_compare($coreVersion, '1.10.0', '<')) {
$this->convertAddressToLegacy();
}
}
/**
* 获取实际 sing-box 核心版本
*
* sing-box 客户端直接报核心版本,hiddify/sfm wrapper 客户端
* 报的是 app 版本,需要映射到对应的 sing-box 核心版本
*/
private function getSingBoxCoreVersion(): ?string
{
if (empty($this->clientVersion)) {
return null;
}
// sing-box 原生客户端,版本即核心版本
if ($this->clientName === 'sing-box') {
return $this->clientVersion;
}
// Hiddify/SFM 等 wrapper 默认内置较新的 sing-box 核心
// 保守策略: 直接按最新格式输出(移除废弃字段),因为这些客户端普遍内置 >= 1.11 的核心
return '1.11.0';
}
/**
* sing-box >= 1.11.0: 移除废弃字段
*/
private function removeDeprecatedFieldsV111(): void
{
if (!isset($this->config['inbounds'])) {
return;
}
foreach ($this->config['inbounds'] as &$inbound) {
unset($inbound['endpoint_independent_nat']);
unset($inbound['sniff_override_destination']);
}
}
/**
* sing-box < 1.10.0: tun address 数组转换为 inet4_address/inet6_address
*/
private function convertAddressToLegacy(): void
{
if (!isset($this->config['inbounds'])) {
return;
}
foreach ($this->config['inbounds'] as &$inbound) {
if ($inbound['type'] !== 'tun' || !isset($inbound['address'])) {
continue;
}
foreach ($inbound['address'] as $addr) {
if (str_contains($addr, ':')) {
$inbound['inet6_address'] = $addr;
} else {
$inbound['inet4_address'] = $addr;
}
}
unset($inbound['address']);
}
}
protected function buildShadowsocks($password, $server) protected function buildShadowsocks($password, $server)
{ {
$protocol_settings = data_get($server, 'protocol_settings'); $protocol_settings = data_get($server, 'protocol_settings');
@@ -193,16 +280,23 @@ class SingBox extends AbstractProtocol
'uuid' => $uuid, 'uuid' => $uuid,
'security' => 'auto', 'security' => 'auto',
'alter_id' => 0, 'alter_id' => 0,
'transport' => [], ];
'tls' => $protocol_settings['tls'] ? [
if ($protocol_settings['tls']) {
$array['tls'] = [
'enabled' => true, 'enabled' => true,
'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'), 'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'),
] : null ];
];
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { $this->appendUtls($array['tls'], $protocol_settings);
$array['tls']['server_name'] = $serverName;
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['tls']['server_name'] = $serverName;
}
} }
$this->appendMultiplex($array, $protocol_settings);
$transport = match ($protocol_settings['network']) { $transport = match ($protocol_settings['network']) {
'tcp' => data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none' ? [ 'tcp' => data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none' ? [
'type' => 'http', 'type' => 'http',
@@ -220,6 +314,20 @@ class SingBox extends AbstractProtocol
'type' => 'grpc', 'type' => 'grpc',
'service_name' => data_get($protocol_settings, 'network_settings.serviceName') 'service_name' => data_get($protocol_settings, 'network_settings.serviceName')
], ],
'h2' => [
'type' => 'http',
'host' => data_get($protocol_settings, 'network_settings.host'),
'path' => data_get($protocol_settings, 'network_settings.path')
],
'httpupgrade' => [
'type' => 'httpupgrade',
'path' => data_get($protocol_settings, 'network_settings.path'),
'host' => data_get($protocol_settings, 'network_settings.host', $server['host']),
'headers' => data_get($protocol_settings, 'network_settings.headers')
],
'quic' => [
'type' => 'quic'
],
default => null default => null
}; };
@@ -246,12 +354,10 @@ class SingBox extends AbstractProtocol
$tlsConfig = [ $tlsConfig = [
'enabled' => true, 'enabled' => true,
'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'), 'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'),
'utls' => [
'enabled' => true,
'fingerprint' => Helper::getRandFingerprint()
]
]; ];
$this->appendUtls($tlsConfig, $protocol_settings);
switch ($protocol_settings['tls']) { switch ($protocol_settings['tls']) {
case 1: case 1:
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
@@ -271,6 +377,8 @@ class SingBox extends AbstractProtocol
$array['tls'] = $tlsConfig; $array['tls'] = $tlsConfig;
} }
$this->appendMultiplex($array, $protocol_settings);
$transport = match ($protocol_settings['network']) { $transport = match ($protocol_settings['network']) {
'tcp' => data_get($protocol_settings, 'network_settings.header.type') == 'http' ? [ 'tcp' => data_get($protocol_settings, 'network_settings.header.type') == 'http' ? [
'type' => 'http', 'type' => 'http',
@@ -298,6 +406,9 @@ class SingBox extends AbstractProtocol
'host' => data_get($protocol_settings, 'network_settings.host', $server['host']), 'host' => data_get($protocol_settings, 'network_settings.host', $server['host']),
'headers' => data_get($protocol_settings, 'network_settings.headers') 'headers' => data_get($protocol_settings, 'network_settings.headers')
], ],
'quic' => [
'type' => 'quic'
],
default => null default => null
}; };
@@ -322,9 +433,15 @@ class SingBox extends AbstractProtocol
'insecure' => (bool) data_get($protocol_settings, 'allow_insecure', false), 'insecure' => (bool) data_get($protocol_settings, 'allow_insecure', false),
] ]
]; ];
$this->appendUtls($array['tls'], $protocol_settings);
if ($serverName = data_get($protocol_settings, 'server_name')) { if ($serverName = data_get($protocol_settings, 'server_name')) {
$array['tls']['server_name'] = $serverName; $array['tls']['server_name'] = $serverName;
} }
$this->appendMultiplex($array, $protocol_settings);
$transport = match (data_get($protocol_settings, 'network')) { $transport = match (data_get($protocol_settings, 'network')) {
'grpc' => [ 'grpc' => [
'type' => 'grpc', 'type' => 'grpc',
@@ -339,7 +456,9 @@ class SingBox extends AbstractProtocol
]), ]),
default => null default => null
}; };
$array['transport'] = $transport; if ($transport) {
$array['transport'] = array_filter($transport, fn($value) => !is_null($value));
}
return $array; return $array;
} }
@@ -505,4 +624,40 @@ class SingBox extends AbstractProtocol
return $array; return $array;
} }
protected function appendMultiplex(&$array, $protocol_settings)
{
if ($multiplex = data_get($protocol_settings, 'multiplex')) {
if (data_get($multiplex, 'enabled')) {
$array['multiplex'] = [
'enabled' => true,
'protocol' => data_get($multiplex, 'protocol', 'yamux'),
'max_connections' => data_get($multiplex, 'max_connections'),
'min_streams' => data_get($multiplex, 'min_streams'),
'max_streams' => data_get($multiplex, 'max_streams'),
'padding' => (bool) data_get($multiplex, 'padding', false),
];
if (data_get($multiplex, 'brutal.enabled')) {
$array['multiplex']['brutal'] = [
'enabled' => true,
'up_mbps' => data_get($multiplex, 'brutal.up_mbps'),
'down_mbps' => data_get($multiplex, 'brutal.down_mbps'),
];
}
$array['multiplex'] = array_filter($array['multiplex'], fn($v) => !is_null($v));
}
}
}
protected function appendUtls(&$tlsConfig, $protocol_settings)
{
if ($utls = data_get($protocol_settings, 'utls')) {
if (data_get($utls, 'enabled')) {
$tlsConfig['utls'] = [
'enabled' => true,
'fingerprint' => Helper::getTlsFingerprint($utls)
];
}
}
}
} }
+32 -27
View File
@@ -18,14 +18,14 @@ class Stash extends AbstractProtocol
Server::TYPE_HYSTERIA, Server::TYPE_HYSTERIA,
Server::TYPE_TROJAN, Server::TYPE_TROJAN,
Server::TYPE_TUIC, Server::TYPE_TUIC,
// Server::TYPE_ANYTLS, Server::TYPE_ANYTLS,
Server::TYPE_SOCKS, Server::TYPE_SOCKS,
Server::TYPE_HTTP, Server::TYPE_HTTP,
]; ];
protected $protocolRequirements = [ protected $protocolRequirements = [
'stash' => [ 'stash' => [
'anytls' => [ 'anytls' => [
'base_version' => '9.9.9' 'base_version' => '3.3.0' // AnyTLS 协议在3.3.0版本中添加
], ],
'vless' => [ 'vless' => [
'protocol_settings.tls' => [ 'protocol_settings.tls' => [
@@ -79,13 +79,7 @@ class Stash extends AbstractProtocol
$user = $this->user; $user = $this->user;
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
$template = admin_setting('subscribe_template_stash', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $template = subscribe_template('stash');
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: (
File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE))
));
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
@@ -251,10 +245,13 @@ class Stash extends AbstractProtocol
switch (data_get($protocol_settings, 'network')) { switch (data_get($protocol_settings, 'network')) {
case 'tcp': case 'tcp':
$array['network'] = data_get($protocol_settings, 'network_settings.header.type', 'http'); $headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
$array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']); $array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
if ($host = data_get($protocol_settings, 'network_settings.header.request.headers.Host')) { if ($headerType === 'http') {
$array['http-opts']['headers']['Host'] = $host; $array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']);
if ($host = data_get($protocol_settings, 'network_settings.header.request.headers.Host')) {
$array['http-opts']['headers']['Host'] = $host;
}
} }
break; break;
case 'ws': case 'ws':
@@ -286,7 +283,9 @@ class Stash extends AbstractProtocol
$array['uuid'] = $uuid; $array['uuid'] = $uuid;
$array['udp'] = true; $array['udp'] = true;
$array['client-fingerprint'] = Helper::getRandFingerprint(); if ($fingerprint = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$array['client-fingerprint'] = $fingerprint;
}
switch (data_get($protocol_settings, 'tls')) { switch (data_get($protocol_settings, 'tls')) {
case 1: case 1:
@@ -312,12 +311,15 @@ class Stash extends AbstractProtocol
switch (data_get($protocol_settings, 'network')) { switch (data_get($protocol_settings, 'network')) {
case 'tcp': case 'tcp':
if ($headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp') != 'tcp') { $headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
$array['network'] = $headerType; $array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
if ($httpOpts = array_filter([ if ($headerType === 'http') {
'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), if (
'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) $httpOpts = array_filter([
])) { 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'),
'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/'])
])
) {
$array['http-opts'] = $httpOpts; $array['http-opts'] = $httpOpts;
} }
} }
@@ -333,11 +335,11 @@ class Stash extends AbstractProtocol
$array['network'] = 'grpc'; $array['network'] = 'grpc';
$array['grpc-opts']['grpc-service-name'] = data_get($protocol_settings, 'network_settings.serviceName'); $array['grpc-opts']['grpc-service-name'] = data_get($protocol_settings, 'network_settings.serviceName');
break; break;
// case 'h2': // case 'h2':
// $array['network'] = 'h2'; // $array['network'] = 'h2';
// $array['h2-opts']['host'] = data_get($protocol_settings, 'network_settings.host'); // $array['h2-opts']['host'] = data_get($protocol_settings, 'network_settings.host');
// $array['h2-opts']['path'] = data_get($protocol_settings, 'network_settings.path'); // $array['h2-opts']['path'] = data_get($protocol_settings, 'network_settings.path');
// break; // break;
} }
return $array; return $array;
@@ -355,8 +357,11 @@ class Stash extends AbstractProtocol
$array['udp'] = true; $array['udp'] = true;
switch (data_get($protocol_settings, 'network')) { switch (data_get($protocol_settings, 'network')) {
case 'tcp': case 'tcp':
$array['network'] = data_get($protocol_settings, 'network_settings.header.type'); $headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
$array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']); $array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
if ($headerType === 'http') {
$array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']);
}
break; break;
case 'ws': case 'ws':
$array['network'] = 'ws'; $array['network'] = 'ws';
+1 -3
View File
@@ -58,9 +58,7 @@ class Surfboard extends AbstractProtocol
} }
} }
$config = admin_setting('subscribe_template_surfboard', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $config = subscribe_template('surfboard');
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
// Subscription link // Subscription link
$subsURL = Helper::getSubscribeUrl($user['token']); $subsURL = Helper::getSubscribeUrl($user['token']);
$subsDomain = request()->header('Host'); $subsDomain = request()->header('Host');
+98 -4
View File
@@ -18,6 +18,9 @@ class Surge extends AbstractProtocol
Server::TYPE_VMESS, Server::TYPE_VMESS,
Server::TYPE_TROJAN, Server::TYPE_TROJAN,
Server::TYPE_HYSTERIA, Server::TYPE_HYSTERIA,
Server::TYPE_ANYTLS,
Server::TYPE_SOCKS,
Server::TYPE_HTTP,
]; ];
protected $protocolRequirements = [ protected $protocolRequirements = [
'surge.hysteria.protocol_settings.version' => [2 => '2398'], 'surge.hysteria.protocol_settings.version' => [2 => '2398'],
@@ -40,7 +43,9 @@ class Surge extends AbstractProtocol
'aes-128-gcm', 'aes-128-gcm',
'aes-192-gcm', 'aes-192-gcm',
'aes-256-gcm', 'aes-256-gcm',
'chacha20-ietf-poly1305' 'chacha20-ietf-poly1305',
'2022-blake3-aes-128-gcm',
'2022-blake3-aes-256-gcm'
]) ])
) { ) {
$proxies .= self::buildShadowsocks($item['password'], $item); $proxies .= self::buildShadowsocks($item['password'], $item);
@@ -58,12 +63,22 @@ class Surge extends AbstractProtocol
$proxies .= self::buildHysteria($item['password'], $item); $proxies .= self::buildHysteria($item['password'], $item);
$proxyGroup .= $item['name'] . ', '; $proxyGroup .= $item['name'] . ', ';
} }
if ($item['type'] === Server::TYPE_ANYTLS) {
$proxies .= self::buildAnyTLS($item['password'], $item);
$proxyGroup .= $item['name'] . ', ';
}
if ($item['type'] === Server::TYPE_SOCKS) {
$proxies .= self::buildSocks($item['password'], $item);
$proxyGroup .= $item['name'] . ', ';
}
if ($item['type'] === Server::TYPE_HTTP) {
$proxies .= self::buildHttp($item['password'], $item);
$proxyGroup .= $item['name'] . ', ';
}
} }
$config = admin_setting('subscribe_template_surge', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $config = subscribe_template('surge');
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
// Subscription link // Subscription link
$subsDomain = request()->header('Host'); $subsDomain = request()->header('Host');
@@ -193,6 +208,28 @@ class Surge extends AbstractProtocol
return $uri; return $uri;
} }
//参考文档: https://manual.nssurge.com/policy/proxy.html
public static function buildAnyTLS($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$config = [
"{$server['name']}=anytls",
"{$server['host']}",
"{$server['port']}",
"password={$password}",
];
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$config[] = "sni={$serverName}";
}
if (data_get($protocol_settings, 'tls.allow_insecure')) {
$config[] = 'skip-cert-verify=true';
}
$config = array_filter($config);
$uri = implode(',', $config);
$uri .= "\r\n";
return $uri;
}
//参考文档: https://manual.nssurge.com/policy/proxy.html //参考文档: https://manual.nssurge.com/policy/proxy.html
public static function buildHysteria($password, $server) public static function buildHysteria($password, $server)
{ {
@@ -222,4 +259,61 @@ class Surge extends AbstractProtocol
$uri .= "\r\n"; $uri .= "\r\n";
return $uri; return $uri;
} }
//参考文档: https://manual.nssurge.com/policy/proxy.html
public static function buildSocks($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$type = data_get($protocol_settings, 'tls') ? 'socks5-tls' : 'socks5';
$config = [
"{$server['name']}={$type}",
"{$server['host']}",
"{$server['port']}",
"{$password}",
"{$password}",
];
if (data_get($protocol_settings, 'tls')) {
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config[] = "sni={$serverName}";
}
if (data_get($protocol_settings, 'tls_settings.allow_insecure')) {
$config[] = 'skip-cert-verify=true';
}
}
$config[] = 'udp-relay=true';
$config = array_filter($config);
$uri = implode(',', $config);
$uri .= "\r\n";
return $uri;
}
//参考文档: https://manual.nssurge.com/policy/proxy.html
public static function buildHttp($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$type = data_get($protocol_settings, 'tls') ? 'https' : 'http';
$config = [
"{$server['name']}={$type}",
"{$server['host']}",
"{$server['port']}",
"{$password}",
"{$password}",
];
if (data_get($protocol_settings, 'tls')) {
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config[] = "sni={$serverName}";
}
if (data_get($protocol_settings, 'tls_settings.allow_insecure')) {
$config[] = 'skip-cert-verify=true';
}
}
$config = array_filter($config);
$uri = implode(',', $config);
$uri .= "\r\n";
return $uri;
}
} }
+12
View File
@@ -2,9 +2,16 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Server;
use App\Models\ServerRoute;
use App\Models\Plan;
use App\Models\User; use App\Models\User;
use App\Observers\PlanObserver;
use App\Observers\ServerObserver;
use App\Observers\ServerRouteObserver;
use App\Observers\UserObserver; use App\Observers\UserObserver;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider class EventServiceProvider extends ServiceProvider
{ {
@@ -24,5 +31,10 @@ class EventServiceProvider extends ServiceProvider
parent::boot(); parent::boot();
User::observe(UserObserver::class); User::observe(UserObserver::class);
Plan::observe(PlanObserver::class);
Server::observe(ServerObserver::class);
ServerRoute::observe(ServerRouteObserver::class);
} }
} }
+1 -5
View File
@@ -23,11 +23,7 @@ class RouteServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
// // HTTPS scheme is forced per-request via middleware (Octane-safe).
if (admin_setting('force_https')) {
resolve(\Illuminate\Routing\UrlGenerator::class)->forceScheme('https');
}
parent::boot(); parent::boot();
} }
+1 -1
View File
@@ -5,7 +5,6 @@ namespace App\Providers;
use App\Support\Setting; use App\Support\Setting;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Log;
class SettingServiceProvider extends ServiceProvider class SettingServiceProvider extends ServiceProvider
{ {
@@ -29,5 +28,6 @@ class SettingServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
// App URL is forced per-request via middleware (Octane-safe).
} }
} }
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Services\Auth; namespace App\Services\Auth;
use App\Exceptions\ApiException;
use App\Models\InviteCode; use App\Models\InviteCode;
use App\Models\Plan; use App\Models\Plan;
use App\Models\User; use App\Models\User;
@@ -113,7 +114,7 @@ class RegisterService
if (!$inviteCodeModel) { if (!$inviteCodeModel) {
if ((int) admin_setting('invite_force', 0)) { if ((int) admin_setting('invite_force', 0)) {
throw new \Exception(__('Invalid invitation code')); throw new ApiException(__('Invalid invitation code'));
} }
return null; return null;
} }
+1 -1
View File
@@ -173,7 +173,7 @@ class GiftCardService
$userService->assignPlan( $userService->assignPlan(
$this->user, $this->user,
$plan, $plan,
$rewards['plan_validity_days'] ?? null $rewards['plan_validity_days'] ?? 0
); );
} }
} else { } else {
+56 -4
View File
@@ -13,6 +13,33 @@ use Illuminate\Support\Facades\Mail;
class MailService class MailService
{ {
// Render {{key}} / {{key|default}} placeholders.
private static function renderPlaceholders(string $template, array $vars): string
{
if ($template === '' || empty($vars)) {
return $template;
}
return (string) preg_replace_callback('/\{\{\s*([a-zA-Z0-9_.-]+)(?:\|([^}]*))?\s*\}\}/', function ($m) use ($vars) {
$key = $m[1] ?? '';
$default = array_key_exists(2, $m) ? trim((string) $m[2]) : null;
if (!array_key_exists($key, $vars) || $vars[$key] === null || $vars[$key] === '') {
return $default !== null ? $default : $m[0];
}
$value = $vars[$key];
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_scalar($value)) {
return (string) $value;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
}, $template);
}
/** /**
* 获取需要发送提醒的用户总数 * 获取需要发送提醒的用户总数
*/ */
@@ -233,19 +260,44 @@ class MailService
} }
if (array_key_exists('content', $params['template_value'])) { if (array_key_exists('content', $params['template_value'])) {
$params['template_value']['content'] = self::sanitizeMailText((string) $params['template_value']['content']); $params['template_value']['content'] = (string) $params['template_value']['content'];
} }
$email = (string) $params['email']; $email = (string) $params['email'];
$subject = self::sanitizeMailText((string) $params['subject']); $originTemplateName = (string) $params['template_name'];
$subject = (string) $params['subject'];
$vars = is_array($params['template_value']) ? ($params['template_value']['vars'] ?? []) : [];
$contentMode = is_array($params['template_value']) ? ($params['template_value']['content_mode'] ?? null) : null;
if (is_array($vars) && !empty($vars)) {
$subject = self::renderPlaceholders($subject, $vars);
if (isset($params['template_value']['content']) && is_string($params['template_value']['content'])) {
$params['template_value']['content'] = self::renderPlaceholders($params['template_value']['content'], $vars);
}
}
$subject = self::sanitizeMailText($subject);
if ($subject === '') { if ($subject === '') {
$subject = 'Notification'; $subject = 'Notification';
} }
$originTemplateName = (string) $params['template_name']; if (array_key_exists('content', $params['template_value'])) {
$params['template_value']['content'] = self::sanitizeMailText((string) $params['template_value']['content']);
}
if (
$contentMode === 'text'
&& $originTemplateName !== 'notify'
&& isset($params['template_value']['content'])
&& is_string($params['template_value']['content'])
) {
$params['template_value']['content'] = e($params['template_value']['content']);
}
$params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $originTemplateName; $params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $originTemplateName;
$logTemplateName = $params['template_name']; $logTemplateName = $params['template_name'];
try { try {
if ($originTemplateName === 'notify') { if ($originTemplateName === 'notify') {
$html = self::buildModernNotifyHtml($params['template_value'], $subject, $appName); $html = self::buildModernNotifyHtml($params['template_value'], $subject, $appName);
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Services;
use Workerman\Connection\TcpConnection;
/**
* In-memory registry for active WebSocket node connections.
* Runs inside the Workerman process.
*/
class NodeRegistry
{
/** @var array<int, TcpConnection> nodeId → connection */
private static array $connections = [];
public static function add(int $nodeId, TcpConnection $conn): void
{
// Close existing connection for this node (if reconnecting)
if (isset(self::$connections[$nodeId])) {
self::$connections[$nodeId]->close();
}
self::$connections[$nodeId] = $conn;
}
public static function remove(int $nodeId): void
{
unset(self::$connections[$nodeId]);
}
public static function get(int $nodeId): ?TcpConnection
{
return self::$connections[$nodeId] ?? null;
}
/**
* Send a JSON message to a specific node.
*/
public static function send(int $nodeId, string $event, array $data): bool
{
$conn = self::get($nodeId);
if (!$conn) {
return false;
}
$payload = json_encode([
'event' => $event,
'data' => $data,
'timestamp' => time(),
]);
$conn->send($payload);
return true;
}
/**
* Get the connection for a node by ID, checking if it's still alive.
*/
public static function isOnline(int $nodeId): bool
{
$conn = self::get($nodeId);
return $conn !== null && $conn->getStatus() === TcpConnection::STATUS_ESTABLISHED;
}
/**
* Get all connected node IDs.
* @return int[]
*/
public static function getConnectedNodeIds(): array
{
return array_keys(self::$connections);
}
public static function count(): int
{
return count(self::$connections);
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Services;
use App\Models\Server;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class NodeSyncService
{
/**
* Check if node has active WS connection
*/
private static function isNodeOnline(int $nodeId): bool
{
return (bool) Cache::get("node_ws_alive:{$nodeId}");
}
/**
* Push node config update
*/
public static function notifyConfigUpdated(int $nodeId): void
{
if (!self::isNodeOnline($nodeId))
return;
$node = Server::find($nodeId);
if (!$node)
return;
self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]);
}
/**
* Push all users to all nodes in the group
*/
public static function notifyUsersUpdatedByGroup(int $groupId): void
{
$servers = Server::whereJsonContains('group_ids', (string) $groupId)
->get();
foreach ($servers as $server) {
if (!self::isNodeOnline($server->id))
continue;
$users = ServerService::getAvailableUsers($server)->toArray();
self::push($server->id, 'sync.users', ['users' => $users]);
}
}
/**
* Push user changes (add/remove) to affected nodes
*/
public static function notifyUserChanged(User $user): void
{
if (!$user->group_id)
return;
$servers = Server::whereJsonContains('group_ids', (string) $user->group_id)->get();
foreach ($servers as $server) {
if (!self::isNodeOnline($server->id))
continue;
if ($user->isAvailable()) {
self::push($server->id, 'sync.user.delta', [
'action' => 'add',
'users' => [
[
'id' => $user->id,
'uuid' => $user->uuid,
'speed_limit' => $user->speed_limit,
'device_limit' => $user->device_limit,
]
],
]);
} else {
self::push($server->id, 'sync.user.delta', [
'action' => 'remove',
'users' => [['id' => $user->id]],
]);
}
}
}
/**
* Push user removal from a specific group's nodes
*/
public static function notifyUserRemovedFromGroup(int $userId, int $groupId): void
{
$servers = Server::whereJsonContains('group_ids', (string) $groupId)
->get();
foreach ($servers as $server) {
if (!self::isNodeOnline($server->id))
continue;
self::push($server->id, 'sync.user.delta', [
'action' => 'remove',
'users' => [['id' => $userId]],
]);
}
}
/**
* Full sync: push config + users to a node
*/
public static function notifyFullSync(int $nodeId): void
{
if (!self::isNodeOnline($nodeId))
return;
$node = Server::find($nodeId);
if (!$node)
return;
self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]);
$users = ServerService::getAvailableUsers($node)->toArray();
self::push($nodeId, 'sync.users', ['users' => $users]);
}
/**
* Publish a push command to Redis picked up by the Workerman WS server
*/
private static function push(int $nodeId, string $event, array $data): void
{
try {
Redis::publish('node:push', json_encode([
'node_id' => $nodeId,
'event' => $event,
'data' => $data,
]));
} catch (\Throwable $e) {
Log::warning("[NodePush] Redis publish failed: {$e->getMessage()}", [
'node_id' => $nodeId,
'event' => $event,
]);
}
}
}
+2 -1
View File
@@ -95,13 +95,14 @@ class OrderService
public function open(): void public function open(): void
{ {
$order = $this->order; $order = $this->order;
$this->user = User::find($order->user_id);
$plan = Plan::find($order->plan_id); $plan = Plan::find($order->plan_id);
HookManager::call('order.open.before', $order); HookManager::call('order.open.before', $order);
DB::transaction(function () use ($order, $plan) { DB::transaction(function () use ($order, $plan) {
$this->user = User::lockForUpdate()->find($order->user_id);
if ($order->refund_amount) { if ($order->refund_amount) {
$this->user->balance += $order->refund_amount; $this->user->balance += $order->refund_amount;
} }
+172 -1
View File
@@ -27,7 +27,9 @@ class ServerService
'is_online', 'is_online',
'available_status', 'available_status',
'cache_key', 'cache_key',
'load_status' 'load_status',
'metrics',
'online_conn'
]); ]);
} }
@@ -54,6 +56,7 @@ class ServerService
$server->port = (int) $server->port; $server->port = (int) $server->port;
} }
$server->password = $server->generateServerPassword($user); $server->password = $server->generateServerPassword($user);
$server->rate = $server->getCurrentRate();
return $server; return $server;
})->toArray(); })->toArray();
@@ -92,6 +95,174 @@ class ServerService
return $routes; return $routes;
} }
/**
* Update node metrics and load status
*/
public static function updateMetrics(Server $node, array $metrics): void
{
$nodeType = strtoupper($node->type);
$nodeId = $node->id;
$cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3);
$metricsData = [
'uptime' => (int) ($metrics['uptime'] ?? 0),
'goroutines' => (int) ($metrics['goroutines'] ?? 0),
'active_connections' => (int) ($metrics['active_connections'] ?? 0),
'total_connections' => (int) ($metrics['total_connections'] ?? 0),
'total_users' => (int) ($metrics['total_users'] ?? 0),
'active_users' => (int) ($metrics['active_users'] ?? 0),
'inbound_speed' => (int) ($metrics['inbound_speed'] ?? 0),
'outbound_speed' => (int) ($metrics['outbound_speed'] ?? 0),
'cpu_per_core' => $metrics['cpu_per_core'] ?? [],
'load' => $metrics['load'] ?? [],
'speed_limiter' => $metrics['speed_limiter'] ?? [],
'gc' => $metrics['gc'] ?? [],
'api' => $metrics['api'] ?? [],
'ws' => $metrics['ws'] ?? [],
'limits' => $metrics['limits'] ?? [],
'updated_at' => now()->timestamp,
'kernel_status' => (bool) ($metrics['kernel_status'] ?? false),
];
\Illuminate\Support\Facades\Cache::put(
\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId),
$metricsData,
$cacheTime
);
}
public static function buildNodeConfig(Server $node): array
{
$nodeType = $node->type;
$protocolSettings = $node->protocol_settings;
$serverPort = $node->server_port;
$host = $node->host;
$baseConfig = [
'protocol' => $nodeType,
'listen_ip' => '0.0.0.0',
'server_port' => (int) $serverPort,
'network' => data_get($protocolSettings, 'network'),
'networkSettings' => data_get($protocolSettings, 'network_settings') ?: null,
];
$response = match ($nodeType) {
'shadowsocks' => [
...$baseConfig,
'cipher' => $protocolSettings['cipher'],
'plugin' => $protocolSettings['plugin'],
'plugin_opts' => $protocolSettings['plugin_opts'],
'server_key' => match ($protocolSettings['cipher']) {
'2022-blake3-aes-128-gcm' => Helper::getServerKey($node->created_at, 16),
'2022-blake3-aes-256-gcm' => Helper::getServerKey($node->created_at, 32),
default => null,
},
],
'vmess' => [
...$baseConfig,
'tls' => (int) $protocolSettings['tls'],
'multiplex' => data_get($protocolSettings, 'multiplex'),
],
'trojan' => [
...$baseConfig,
'host' => $host,
'server_name' => $protocolSettings['server_name'],
'multiplex' => data_get($protocolSettings, 'multiplex'),
],
'vless' => [
...$baseConfig,
'tls' => (int) $protocolSettings['tls'],
'flow' => $protocolSettings['flow'],
'tls_settings' => match ((int) $protocolSettings['tls']) {
2 => $protocolSettings['reality_settings'],
default => $protocolSettings['tls_settings'],
},
'multiplex' => data_get($protocolSettings, 'multiplex'),
],
'hysteria' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'version' => (int) $protocolSettings['version'],
'host' => $host,
'server_name' => $protocolSettings['tls']['server_name'],
'up_mbps' => (int) $protocolSettings['bandwidth']['up'],
'down_mbps' => (int) $protocolSettings['bandwidth']['down'],
...match ((int) $protocolSettings['version']) {
1 => ['obfs' => $protocolSettings['obfs']['password'] ?? null],
2 => [
'obfs' => $protocolSettings['obfs']['open'] ? $protocolSettings['obfs']['type'] : null,
'obfs-password' => $protocolSettings['obfs']['password'] ?? null,
],
default => [],
},
],
'tuic' => [
...$baseConfig,
'version' => (int) $protocolSettings['version'],
'server_port' => (int) $serverPort,
'server_name' => $protocolSettings['tls']['server_name'],
'congestion_control' => $protocolSettings['congestion_control'],
'tls_settings' => data_get($protocolSettings, 'tls_settings'),
'auth_timeout' => '3s',
'zero_rtt_handshake' => false,
'heartbeat' => '3s',
],
'anytls' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'server_name' => $protocolSettings['tls']['server_name'],
'padding_scheme' => $protocolSettings['padding_scheme'],
],
'socks' => [
...$baseConfig,
'server_port' => (int) $serverPort,
],
'naive' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'tls' => (int) $protocolSettings['tls'],
'tls_settings' => $protocolSettings['tls_settings'],
],
'http' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'tls' => (int) $protocolSettings['tls'],
'tls_settings' => $protocolSettings['tls_settings'],
],
'mieru' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'transport' => data_get($protocolSettings, 'transport', 'TCP'),
'traffic_pattern' => $protocolSettings['traffic_pattern'],
// 'multiplex' => data_get($protocolSettings, 'multiplex'),
],
default => [],
};
$response = array_filter(
$response,
static fn ($value) => $value !== null
);
if (!empty($node['route_ids'])) {
$response['routes'] = self::getRoutes($node['route_ids']);
}
if (!empty($node['custom_outbounds'])) {
$response['custom_outbounds'] = $node['custom_outbounds'];
}
if (!empty($node['custom_routes'])) {
$response['custom_routes'] = $node['custom_routes'];
}
if (!empty($node['cert_config'])) {
$response['cert_config'] = $node['cert_config'];
}
return $response;
}
/** /**
* 根据协议类型和标识获取服务器 * 根据协议类型和标识获取服务器
* @param int $serverId * @param int $serverId
+2 -1
View File
@@ -173,7 +173,7 @@ class UserService
// 默认设置 // 默认设置
$user->remind_expire = admin_setting('default_remind_expire', 1); $user->remind_expire = admin_setting('default_remind_expire', 1);
$user->remind_traffic = admin_setting('default_remind_traffic', 1); $user->remind_traffic = admin_setting('default_remind_traffic', 1);
$user->expired_at = 0; $user->expired_at = null;
// 可选字段 // 可选字段
$this->setOptionalFields($user, $data); $this->setOptionalFields($user, $data);
@@ -242,6 +242,7 @@ class UserService
$user->group_id = $plan->group_id; $user->group_id = $plan->group_id;
$user->transfer_enable = $plan->transfer_enable * 1073741824; $user->transfer_enable = $plan->transfer_enable * 1073741824;
$user->speed_limit = $plan->speed_limit; $user->speed_limit = $plan->speed_limit;
$user->device_limit = $plan->device_limit;
if ($validityDays > 0) { if ($validityDays > 0) {
$user = $this->extendSubscription($user, $validityDays); $user = $this->extendSubscription($user, $validityDays);
+3 -1
View File
@@ -26,6 +26,8 @@ class CacheKey
'SERVER_*_LAST_PUSH_AT', // 节点最后推送时间 'SERVER_*_LAST_PUSH_AT', // 节点最后推送时间
'SERVER_*_LOAD_STATUS', // 节点负载状态 'SERVER_*_LOAD_STATUS', // 节点负载状态
'SERVER_*_LAST_LOAD_AT', // 节点最后负载提交时间 'SERVER_*_LAST_LOAD_AT', // 节点最后负载提交时间
'SERVER_*_METRICS', // 节点指标数据
'USER_ONLINE_CONN_*_*', // 用户在线连接数 (特定节点类型_ID)
]; ];
/** /**
@@ -57,7 +59,7 @@ class CacheKey
private static function matchesPattern(string $key): bool private static function matchesPattern(string $key): bool
{ {
foreach (self::ALLOWED_PATTERNS as $pattern) { foreach (self::ALLOWED_PATTERNS as $pattern) {
$regex = '/^' . str_replace('*', '[A-Z_]+', $pattern) . '$/'; $regex = '/^' . str_replace('*', '[A-Za-z0-9_]+', $pattern) . '$/';
if (preg_match($regex, $key)) { if (preg_match($regex, $key)) {
return true; return true;
} }
+20 -3
View File
@@ -142,8 +142,13 @@ class Helper
} }
public static function randomPort($range): int { public static function randomPort($range): int {
$portRange = explode('-', $range); $portRange = explode('-', (string) $range, 2);
return random_int((int)$portRange[0], (int)$portRange[1]); $min = (int) ($portRange[0] ?? 0);
$max = (int) ($portRange[1] ?? $portRange[0] ?? 0);
if ($min > $max) {
[$min, $max] = [$max, $min];
}
return random_int($min, $max);
} }
public static function base64EncodeUrlSafe($data) public static function base64EncodeUrlSafe($data)
@@ -183,8 +188,20 @@ class Helper
public static function getIpByDomainName($domain) { public static function getIpByDomainName($domain) {
return gethostbynamel($domain) ?: []; return gethostbynamel($domain) ?: [];
} }
public static function getTlsFingerprint($utls = null)
{
if (is_array($utls) || is_object($utls)) {
if (!data_get($utls, 'enabled')) {
return null;
}
$fingerprint = data_get($utls, 'fingerprint', 'chrome');
if ($fingerprint !== 'random') {
return $fingerprint;
}
}
public static function getRandFingerprint() {
$fingerprints = ['chrome', 'firefox', 'safari', 'ios', 'edge', 'qq']; $fingerprints = ['chrome', 'firefox', 'safari', 'ios', 'edge', 'qq'];
return Arr::random($fingerprints); return Arr::random($fingerprints);
} }
+15
View File
@@ -33,9 +33,24 @@ services:
command: php artisan horizon command: php artisan horizon
depends_on: depends_on:
- redis - redis
ws-server:
image: xboard-local:latest
volumes:
- ./.docker/.data/redis/:/data/
- ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs
- ./plugins:/www/plugins
restart: on-failure
# network_mode: host
command: php artisan ws-server start
depends_on:
- redis
redis: redis:
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
volumes: volumes:
- ./.docker/.data/redis:/data - ./.docker/.data/redis:/data
sysctls:
net.core.somaxconn: 1024
+3
View File
@@ -31,6 +31,9 @@
"symfony/http-client": "^7.0", "symfony/http-client": "^7.0",
"symfony/mailgun-mailer": "^7.0", "symfony/mailgun-mailer": "^7.0",
"symfony/yaml": "*", "symfony/yaml": "*",
"webmozart/assert": "*",
"workerman/redis": "^2.0",
"workerman/workerman": "^5.1",
"zoujingli/ip2region": "^2.0" "zoujingli/ip2region": "^2.0"
}, },
"require-dev": { "require-dev": {
+3
View File
@@ -41,6 +41,9 @@ return [
'database' => env('DB_DATABASE') ? base_path(env('DB_DATABASE')) : database_path('database.sqlite'), 'database' => env('DB_DATABASE') ? base_path(env('DB_DATABASE')) : database_path('database.sqlite'),
'prefix' => '', 'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => env('DB_BUSY_TIMEOUT', 30000),
'journal_mode' => env('DB_JOURNAL_MODE', 'wal'),
'synchronous' => env('DB_SYNCHRONOUS', 'normal'),
], ],
'mysql' => [ 'mysql' => [
+40 -4
View File
@@ -60,7 +60,7 @@ return [
'prefix' => env( 'prefix' => env(
'HORIZON_PREFIX', 'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:' Str::slug(env('APP_NAME', 'laravel'), '_') . '_horizon:'
), ),
/* /*
@@ -155,7 +155,7 @@ return [
| |
*/ */
'memory_limit' => 64, 'memory_limit' => 256,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -169,22 +169,58 @@ return [
*/ */
'environments' => [ 'environments' => [
'production' => [
'data-pipeline' => [
'connection' => 'redis',
'queue' => ['traffic_fetch', 'stat', 'user_alive_sync'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'minProcesses' => 1,
'maxProcesses' => 8,
'balanceCooldown' => 1,
'tries' => 3,
'timeout' => 30,
],
'business' => [
'connection' => 'redis',
'queue' => ['default', 'order_handle'],
'balance' => 'simple',
'minProcesses' => 1,
'maxProcesses' => 3,
'tries' => 3,
'timeout' => 30,
],
'notification' => [
'connection' => 'redis',
'queue' => ['send_email', 'send_telegram', 'send_email_mass', 'node_sync'],
'balance' => 'auto',
'autoScalingStrategy' => 'size',
'minProcesses' => 1,
'maxProcesses' => 3,
'tries' => 3,
'timeout' => 60,
'backoff' => [3, 10, 30],
],
],
'local' => [ 'local' => [
'Xboard' => [ 'Xboard' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => [ 'queue' => [
'default',
'order_handle', 'order_handle',
'traffic_fetch', 'traffic_fetch',
'stat', 'stat',
'send_email', 'send_email',
'send_email_mass', 'send_email_mass',
'send_telegram', 'send_telegram',
'online_sync' 'user_alive_sync',
'node_sync'
], ],
'balance' => 'auto', 'balance' => 'auto',
'minProcesses' => 1, 'minProcesses' => 1,
'maxProcesses' => 20, 'maxProcesses' => 5,
'tries' => 1, 'tries' => 1,
'timeout' => 60,
'balanceCooldown' => 3, 'balanceCooldown' => 3,
], ],
], ],
+6 -54
View File
@@ -5,40 +5,9 @@ use Monolog\Handler\SyslogUdpHandler;
return [ return [
/* 'default' => env('LOG_CHANNEL', 'daily'),
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that gets used when writing
| messages to the logs. The name specified in this option should match
| one of the channels defined in the "channels" configuration array.
|
*/
'default' => 'mysql',
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Out of
| the box, Laravel uses the Monolog PHP logging library. This gives
| you a variety of powerful log handlers / formatters to utilize.
|
| Available Drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog",
| "custom", "stack"
|
*/
'channels' => [ 'channels' => [
'mysql' => [
'driver' => 'custom',
'via' => App\Logging\MysqlLogger::class,
],
'stack' => [ 'stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => ['daily'], 'channels' => ['daily'],
@@ -54,36 +23,19 @@ return [
'single' => [ 'single' => [
'driver' => 'single', 'driver' => 'single',
'path' => storage_path('logs/laravel.log'), 'path' => storage_path('logs/laravel.log'),
'level' => 'debug', 'level' => env('LOG_LEVEL', 'debug'),
], ],
'daily' => [ 'daily' => [
'driver' => 'daily', 'driver' => 'daily',
'path' => storage_path('logs/laravel.log'), 'path' => storage_path('logs/laravel.log'),
'level' => 'debug', 'level' => env('LOG_LEVEL', 'debug'),
'days' => 14, 'days' => 14,
], ],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
'papertrail' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => SyslogUdpHandler::class,
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
],
],
'stderr' => [ 'stderr' => [
'driver' => 'monolog', 'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class, 'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'), 'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [ 'with' => [
@@ -93,12 +45,12 @@ return [
'syslog' => [ 'syslog' => [
'driver' => 'syslog', 'driver' => 'syslog',
'level' => 'debug', 'level' => env('LOG_LEVEL', 'debug'),
], ],
'errorlog' => [ 'errorlog' => [
'driver' => 'errorlog', 'driver' => 'errorlog',
'level' => 'debug', 'level' => env('LOG_LEVEL', 'debug'),
], ],
'deprecations' => [ 'deprecations' => [
+8 -8
View File
@@ -79,7 +79,7 @@ return [
], ],
RequestTerminated::class => [ RequestTerminated::class => [
// FlushUploadedFiles::class, FlushUploadedFiles::class,
], ],
TaskReceived::class => [ TaskReceived::class => [
@@ -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 => [
@@ -132,7 +132,7 @@ return [
], ],
'flush' => [ 'flush' => [
// \App\Services\Plugin\HookManager::class,
], ],
/* /*
@@ -147,8 +147,8 @@ return [
*/ */
'cache' => [ 'cache' => [
'rows' => 1000, 'rows' => 5000,
'bytes' => 10000, 'bytes' => 20000,
], ],
/* /*
@@ -203,7 +203,7 @@ return [
| |
*/ */
'garbage' => 50, 'garbage' => 128,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -216,6 +216,6 @@ return [
| |
*/ */
'max_execution_time' => 30, 'max_execution_time' => 60,
]; ];
@@ -17,7 +17,7 @@ class CreateV2SettingsTable extends Migration
$table->id(); $table->id();
$table->string('group')->comment('设置分组')->nullable(); $table->string('group')->comment('设置分组')->nullable();
$table->string('type')->comment('设置类型')->nullable(); $table->string('type')->comment('设置类型')->nullable();
$table->string('name')->comment('设置名称')->uniqid(); $table->string('name')->comment('设置名称')->unique();
$table->string('value')->comment('设置值')->nullable(); $table->string('value')->comment('设置值')->nullable();
$table->timestamps(); $table->timestamps();
}); });
@@ -0,0 +1,91 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
return new class extends Migration
{
public function up(): void
{
Schema::create('v2_subscribe_templates', function (Blueprint $table) {
$table->id();
$table->string('name')->unique()->comment('Template key, e.g. singbox, clash');
$table->mediumText('content')->nullable()->comment('Template content');
$table->timestamps();
});
$this->seedDefaults();
}
public function down(): void
{
Schema::dropIfExists('v2_subscribe_templates');
}
private function seedDefaults(): void
{
// Fallback order matches original protocol class behavior
$protocols = [
'singbox' => [
'resources/rules/custom.sing-box.json',
'resources/rules/default.sing-box.json',
],
'clash' => [
'resources/rules/custom.clash.yaml',
'resources/rules/default.clash.yaml',
],
'clashmeta' => [
'resources/rules/custom.clashmeta.yaml',
'resources/rules/custom.clash.yaml',
'resources/rules/default.clash.yaml',
],
'stash' => [
'resources/rules/custom.stash.yaml',
'resources/rules/custom.clash.yaml',
'resources/rules/default.clash.yaml',
],
'surge' => [
'resources/rules/custom.surge.conf',
'resources/rules/default.surge.conf',
],
'surfboard' => [
'resources/rules/custom.surfboard.conf',
'resources/rules/default.surfboard.conf',
],
];
foreach ($protocols as $name => $fileFallbacks) {
$existing = DB::table('v2_settings')
->where('name', "subscribe_template_{$name}")
->value('value');
if ($existing !== null && $existing !== '') {
$content = $existing;
} else {
$content = '';
foreach ($fileFallbacks as $file) {
$path = base_path($file);
if (File::exists($path)) {
$content = File::get($path);
break;
}
}
}
DB::table('v2_subscribe_templates')->insert([
'name' => $name,
'content' => $content,
'created_at' => now(),
'updated_at' => now(),
]);
}
// Clean up old entries from v2_settings
DB::table('v2_settings')
->where('name', 'like', 'subscribe_template_%')
->delete();
}
};
@@ -0,0 +1,28 @@
<?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_admin_audit_log', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('admin_id')->index();
$table->string('action', 64)->index()->comment('Action identifier e.g. user.update');
$table->string('method', 10);
$table->string('uri', 512);
$table->text('request_data')->nullable();
$table->string('ip', 128)->nullable();
$table->unsignedInteger('created_at');
$table->unsignedInteger('updated_at');
});
}
public function down(): void
{
Schema::dropIfExists('v2_admin_audit_log');
}
};
@@ -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_stat_user', function (Blueprint $table) {
$table->index(['record_at', 'user_id'], 'idx_stat_user_record_user');
});
}
public function down(): void
{
Schema::table('v2_stat_user', function (Blueprint $table) {
$table->dropIndex('idx_stat_user_record_user');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('v2_server', function (Blueprint $table) {
$table->json('custom_outbounds')->nullable()->after('protocol_settings');
$table->json('custom_routes')->nullable()->after('custom_outbounds');
$table->json('cert_config')->nullable()->after('custom_routes');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v2_server', function (Blueprint $table) {
$table->dropColumn(['custom_outbounds', 'custom_routes', 'cert_config']);
});
}
};
+94 -62
View File
@@ -1,38 +1,48 @@
# 1Panel 快速部署指南 # Quick Deployment Guide for 1Panel
本文档介绍如何使用 1Panel 部署 Xboard。 This guide explains how to deploy Xboard using 1Panel.
## 1. 环境准备 ## 1. Environment Preparation
安装 1Panel Install 1Panel:
```bash ```bash
curl -sSL https://resource.fit2cloud.com/1panel/package/quick_start.sh -o quick_start.sh && \ curl -sSL https://resource.fit2cloud.com/1panel/package/quick_start.sh -o quick_start.sh && \
sudo bash quick_start.sh sudo bash quick_start.sh
``` ```
## 2. 环境配置 ## 2. Environment Configuration
1. 从应用商店安装: 1. Install from App Store:
- OpenResty(任意版本) - OpenResty (any version)
- 勾选“外部端口访问”以放行防火墙 - ⚠️ Check "External Port Access" to open firewall
- MySQL 5.7ARM 架构请使用 MariaDB - MySQL 5.7 (Use MariaDB for ARM architecture)
2. 创建数据库: 2. Create Database:
- 数据库名:`xboard` - Database name: `xboard`
- 用户名:`xboard` - Username: `xboard`
- 权限:所有主机(% - Access rights: All hosts (%)
- 请保存数据库密码,安装时需要使用 - Save the database password for installation
## 3. 部署步骤 ## 3. Deployment Steps
1. 添加网站: 1. Add Website:
- 进入“网站” > “创建网站” > “反向代理” - Go to "Website" > "Create Website" > "Reverse Proxy"
- 域名:填写你的域名 - Domain: Enter your domain
- 代号:`xboard` - Code: `xboard`
- 代理地址:`127.0.0.1:7001` - Proxy address: `127.0.0.1:7001`
2. 配置反向代理: 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;
@@ -49,31 +59,32 @@ location ^~ / {
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.
3. 安装 Xboard 3. Install Xboard:
```bash ```bash
# 进入网站目录 # Enter site directory
cd /opt/1panel/apps/openresty/openresty/www/sites/xboard/index cd /opt/1panel/apps/openresty/openresty/www/sites/xboard/index
# 安装 Git(未安装时执行) # Install Git (if not installed)
## Ubuntu/Debian ## Ubuntu/Debian
apt update && apt install -y git apt update && apt install -y git
## CentOS/RHEL ## CentOS/RHEL
yum update && yum install -y git yum update && yum install -y git
# 克隆仓库 # Clone repository
git clone -b compose --depth 1 https://github.com/Micah123321/Xboard ./ git clone -b compose --depth 1 https://github.com/cedar2025/Xboard ./
# 配置 Docker Compose # Configure Docker Compose
``` ```
4. 编辑 compose.yaml 4. Edit compose.yaml:
```yaml ```yaml
services: services:
web: web:
image: ghcr.io/Micah123321/xboard:new image: ghcr.io/cedar2025/xboard:new
volumes: volumes:
- ./.docker/.data/redis/:/data/ - redis-data:/data
- ./.env:/www/.env - ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data - ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs - ./storage/logs:/www/storage/logs
@@ -91,9 +102,9 @@ services:
- 1panel-network - 1panel-network
horizon: horizon:
image: ghcr.io/Micah123321/xboard:new image: ghcr.io/cedar2025/xboard:new
volumes: volumes:
- ./.docker/.data/redis/:/data/ - redis-data:/data
- ./.env:/www/.env - ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data - ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs - ./storage/logs:/www/storage/logs
@@ -104,6 +115,22 @@ services:
- 1panel-network - 1panel-network
depends_on: depends_on:
- redis - 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: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -112,67 +139,72 @@ services:
networks: networks:
- 1panel-network - 1panel-network
volumes: volumes:
- ./.docker/.data/redis:/data - redis-data:/data
volumes:
redis-data:
networks: networks:
1panel-network: 1panel-network:
external: true external: true
``` ```
5. 初始化安装: 5. Initialize Installation:
```bash ```bash
# 安装依赖并初始化 # Install dependencies and initialize
docker compose run -it --rm web php artisan xboard:install docker compose run -it --rm web php artisan xboard:install
``` ```
重要配置说明: ⚠️ Important Configuration Notes:
1. 数据库配置 1. Database Configuration
- Database Host:按部署方式填写: - Database Host: Choose based on your deployment:
1. 如果数据库与 Xboard 在同一网络,填写 `mysql` 1. If database and Xboard are in the same network, use `mysql`
2. 如果连接失败,进入:数据库 -> 选择数据库 -> 连接信息 -> 容器连接,使用其中的 Host 值 2. If connection fails, go to: Database -> Select Database -> Connection Info -> Container Connection, and use the "Host" value
3. 如果使用外部数据库,填写实际数据库地址 3. If using external database, enter your actual database host
- Database Port`3306`(默认端口,除非你另有配置) - Database Port: `3306` (default port unless configured otherwise)
- Database Name`xboard`(前面创建的数据库) - Database Name: `xboard` (the database created earlier)
- Database User`xboard`(前面创建的用户) - Database User: `xboard` (the user created earlier)
- Database Password:填写前面保存的密码 - Database Password: Enter the password saved earlier
2. Redis 配置 2. Redis Configuration
- 选择使用内置 Redis - Choose to use built-in Redis
- 无需额外配置 - No additional configuration needed
3. 管理员信息 3. Administrator Information
- 保存安装完成后显示的管理员账号信息 - Save the admin credentials displayed after installation
- 记录管理后台访问地址 - Note down the admin panel access URL
配置完成后,启动服务: After configuration, start the services:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
6. 启动服务: 6. Start Services:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
## 4. 版本更新 ## 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 run -it --rm web php artisan xboard:update && \ docker compose run -it --rm web php artisan xboard:update && \
docker compose up -d docker compose up -d
``` ```
> - 如果是较早安装(旧版本),请把 `web` 替换为 `xboard` > - If you installed earlier (old version), replace `web` with `xboard`:
```bash ```bash
docker compose pull && \ docker compose pull && \
docker compose run -it --rm xboard php artisan xboard:update && \ docker compose run -it --rm xboard 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.
## 重要提示 ## Important Notes
- 请确保已开启防火墙,避免 7001 端口直接暴露到公网 - ⚠️ Ensure firewall is enabled to prevent port 7001 exposure to public
- 代码修改后需要重启服务才能生效 - Service restart is required after code modifications
- 建议配置 SSL 证书以保障访问安全 - SSL certificate configuration is recommended for secure access
> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side.
+76 -62
View File
@@ -1,89 +1,99 @@
# aaPanel + Docker 环境下的 Xboard 部署指南 # Xboard Deployment Guide for aaPanel + Docker Environment
## 目录 ## Table of Contents
1. [环境要求](#环境要求) 1. [Requirements](#requirements)
2. [快速部署](#快速部署) 2. [Quick Deployment](#quick-deployment)
3. [详细配置](#详细配置) 3. [Detailed Configuration](#detailed-configuration)
4. [维护指南](#维护指南) 4. [Maintenance Guide](#maintenance-guide)
5. [故障排查](#故障排查) 5. [Troubleshooting](#troubleshooting)
## 环境要求 ## Requirements
### 硬件要求 ### Hardware Requirements
- CPU1 核及以上 - CPU: 1 core or above
- 内存:2GB 及以上 - Memory: 2GB or above
- 存储:可用空间 10GB+ - Storage: 10GB+ available space
### 软件要求 ### Software Requirements
- 操作系统:Ubuntu 20.04+ / CentOS 7+ / Debian 10+ - Operating System: Ubuntu 20.04+ / CentOS 7+ / Debian 10+
- aaPanel 最新版本 - Latest version of aaPanel
- Docker Docker Compose - Docker and Docker Compose
- Nginx(任意版本) - Nginx (any version)
- MySQL 5.7+ - MySQL 5.7+
## 快速部署 ## Quick Deployment
### 1. 安装 aaPanel ### 1. Install aaPanel
```bash ```bash
curl -sSL https://www.aapanel.com/script/install_6.0_en.sh -o install_6.0_en.sh && \ curl -sSL https://www.aapanel.com/script/install_6.0_en.sh -o install_6.0_en.sh && \
bash install_6.0_en.sh aapanel bash install_6.0_en.sh aapanel
``` ```
### 2. 基础环境配置 ### 2. Basic Environment Setup
#### 2.1 安装 Docker #### 2.1 Install Docker
```bash ```bash
# 安装 Docker # Install Docker
curl -sSL https://get.docker.com | bash curl -sSL https://get.docker.com | bash
# CentOS 系统还需要执行: # For CentOS systems, also run:
systemctl enable docker systemctl enable docker
systemctl start docker systemctl start docker
``` ```
#### 2.2 安装必需组件 #### 2.2 Install Required Components
aaPanel 面板中安装: In the aaPanel dashboard, install:
- Nginx(任意版本) - Nginx (any version)
- MySQL 5.7 - MySQL 5.7
- PHP Redis 不需要安装 - ⚠️ PHP and Redis are not required
### 3. 网站配置 ### 3. Site Configuration
#### 3.1 创建网站 #### 3.1 Create Website
1. 进入:aaPanel > 网站 > 添加站点 1. Navigate to: aaPanel > Website > Add site
2. 填写信息: 2. Fill in the information:
- 域名:填写你的网站域名 - Domain: Enter your site domain
- 数据库:选择 MySQL - Database: Select MySQL
- PHP 版本:选择纯静态 - PHP Version: Select Pure Static
#### 3.2 部署 Xboard #### 3.2 Deploy Xboard
```bash ```bash
# 进入网站目录 # Enter site directory
cd /www/wwwroot/your-domain cd /www/wwwroot/your-domain
# 清理目录 # Clean directory
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
git clone https://github.com/Micah123321/Xboard.git ./ git clone https://github.com/cedar2025/Xboard.git ./
# 准备配置文件 # Prepare configuration file
cp compose.sample.yaml compose.yaml cp compose.sample.yaml compose.yaml
# 安装依赖并初始化 # Install dependencies and initialize
docker compose run -it --rm web sh init.sh docker compose run -it --rm web sh init.sh
``` ```
> 请保存安装完成后显示的管理后台地址、用户名和密码 > ⚠️ Please save the admin dashboard URL, username, and password shown after installation
#### 3.3 启动服务 #### 3.3 Start Services
```bash ```bash
docker compose up -d docker compose up -d
``` ```
#### 3.4 配置反向代理 #### 3.4 Configure Reverse Proxy
将以下内容添加到网站配置: 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;
@@ -100,19 +110,20 @@ location ^~ / {
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.
## 维护指南 ## Maintenance Guide
### 版本更新 ### 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 run -it --rm web sh update.sh && \ docker compose run -it --rm web sh update.sh && \
docker compose up -d docker compose up -d
``` ```
> - 如果是较早安装(旧版本),请把 `web` 替换为 `xboard` > - For older installations, replace `web` with `xboard`:
```bash ```bash
git config --global --add safe.directory $(pwd) git config --global --add safe.directory $(pwd)
git fetch --all && git reset --hard origin/master && git pull origin master git fetch --all && git reset --hard origin/master && git pull origin master
@@ -120,18 +131,21 @@ docker compose pull && \
docker compose run -it --rm xboard sh update.sh && \ docker compose run -it --rm xboard sh update.sh && \
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
- 定期查看日志:`docker compose logs` - Regular log checking: `docker compose logs`
- 监控系统资源使用情况 - Monitor system resource usage
- 定期备份数据库和配置文件 - Regular backup of database and configuration files
## 故障排查 ## Troubleshooting
如果在安装或运行中遇到问题,请检查: If you encounter any issues during installation or operation, please check:
1. 系统要求是否满足 1. **Empty Admin Dashboard**: If the admin panel is blank, run `git submodule update --init --recursive --force` to restore the theme files.
2. 所有必需端口是否可用 2. System requirements are met
3. Docker 服务是否正常运行 3. All required ports are available
4. Nginx 配置是否正确 3. Docker services are running properly
5. 查看日志以获取详细报错信息 4. Nginx configuration is correct
5. Check logs for detailed error messages
> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side.
+116 -81
View File
@@ -1,46 +1,46 @@
# aaPanel 环境下的 Xboard 部署指南 # Xboard Deployment Guide for aaPanel Environment
## 目录 ## Table of Contents
1. [环境要求](#环境要求) 1. [Requirements](#requirements)
2. [快速部署](#快速部署) 2. [Quick Deployment](#quick-deployment)
3. [详细配置](#详细配置) 3. [Detailed Configuration](#detailed-configuration)
4. [维护指南](#维护指南) 4. [Maintenance Guide](#maintenance-guide)
5. [故障排查](#故障排查) 5. [Troubleshooting](#troubleshooting)
## 环境要求 ## Requirements
### 硬件要求 ### Hardware Requirements
- CPU1 核及以上 - CPU: 1 core or above
- 内存:2GB 及以上 - Memory: 2GB or above
- 存储:可用空间 10GB+ - Storage: 10GB+ available space
### 软件要求 ### Software Requirements
- 操作系统:Ubuntu 20.04+ / Debian 10+CentOS 7 不推荐) - Operating System: Ubuntu 20.04+ / Debian 10+ (⚠️ CentOS 7 is not recommended)
- aaPanel 最新版本 - Latest version of aaPanel
- PHP 8.2 - PHP 8.2
- MySQL 5.7+ - MySQL 5.7+
- Redis - Redis
- Nginx(任意版本) - Nginx (any version)
## 快速部署 ## Quick Deployment
### 1. 安装 aaPanel ### 1. Install aaPanel
```bash ```bash
URL=https://www.aapanel.com/script/install_6.0_en.sh && \ URL=https://www.aapanel.com/script/install_6.0_en.sh && \
if [ -f /usr/bin/curl ];then curl -ksSO "$URL" ;else wget --no-check-certificate -O install_6.0_en.sh "$URL";fi && \ if [ -f /usr/bin/curl ];then curl -ksSO "$URL" ;else wget --no-check-certificate -O install_6.0_en.sh "$URL";fi && \
bash install_6.0_en.sh aapanel bash install_6.0_en.sh aapanel
``` ```
### 2. 基础环境配置 ### 2. Basic Environment Setup
#### 2.1 安装 LNMP 环境 #### 2.1 Install LNMP Environment
aaPanel 面板中安装: In the aaPanel dashboard, install:
- Nginx(任意版本) - Nginx (any version)
- MySQL 5.7 - MySQL 5.7
- PHP 8.2 - PHP 8.2
#### 2.2 安装 PHP 扩展 #### 2.2 Install PHP Extensions
必需的 PHP 扩展: Required PHP extensions:
- redis - redis
- fileinfo - fileinfo
- swoole - swoole
@@ -48,41 +48,41 @@ bash install_6.0_en.sh aapanel
- event - event
- mbstring - mbstring
#### 2.3 启用所需 PHP 函数 #### 2.3 Enable Required PHP Functions
需要启用的函数: Functions that need to be enabled:
- putenv - putenv
- proc_open - proc_open
- pcntl_alarm - pcntl_alarm
- pcntl_signal - pcntl_signal
### 3. 网站配置 ### 3. Site Configuration
#### 3.1 创建网站 #### 3.1 Create Website
1. 进入:aaPanel > 网站 > 添加站点 1. Navigate to: aaPanel > Website > Add site
2. 填写信息: 2. Fill in the information:
- 域名:填写你的网站域名 - Domain: Enter your site domain
- 数据库:选择 MySQL - Database: Select MySQL
- PHP 版本:选择 8.2 - PHP Version: Select 8.2
#### 3.2 部署 Xboard #### 3.2 Deploy Xboard
```bash ```bash
# 进入网站目录 # Enter site directory
cd /www/wwwroot/your-domain cd /www/wwwroot/your-domain
# 清理目录 # Clean directory
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
git clone https://github.com/Micah123321/Xboard.git ./ git clone https://github.com/cedar2025/Xboard.git ./
# 安装依赖 # Install dependencies
sh init.sh sh init.sh
``` ```
#### 3.3 配置网站 #### 3.3 Configure Site
1. 运行目录设置为 `/public` 1. Set running directory to `/public`
2. 添加伪静态规则: 2. Add rewrite rules:
```nginx ```nginx
location /downloads { location /downloads {
} }
@@ -99,33 +99,33 @@ location ~ .*\.(js|css)?$
} }
``` ```
## 详细配置 ## Detailed Configuration
### 1. 配置守护进程 ### 1. Configure Daemon Process
1. 安装 Supervisor 1. Install Supervisor
2. 添加队列守护进程: 2. Add queue daemon process:
- 名称:`Xboard` - Name: `Xboard`
- 运行用户:`www` - Run User: `www`
- 运行目录:网站目录 - Running Directory: Site directory
- 启动命令:`php artisan horizon` - Start Command: `php artisan horizon`
- 进程数量:1 - Process Count: 1
### 2. 配置计划任务 ### 2. Configure Scheduled Tasks
- 类型:Shell 脚本 - Type: Shell Script
- 任务名称:v2board - Task Name: v2board
- 执行用户:www - Run User: www
- 执行周期:每 1 分钟 - Frequency: 1 minute
- 脚本内容:`php /www/wwwroot/site-directory/artisan schedule:run` - Script Content: `php /www/wwwroot/site-directory/artisan schedule:run`
### 3. Octane 配置(可选) ### 3. Octane Configuration (Optional)
#### 3.1 添加 Octane 守护进程 #### 3.1 Add Octane Daemon Process
- 名称:Octane - Name: Octane
- 运行用户:www - Run User: www
- 运行目录:网站目录 - Running Directory: Site directory
- 启动命令:`/www/server/php/82/bin/php artisan octane:start --port 7010` - Start Command: `/www/server/php/82/bin/php artisan octane:start --port 7010`
- 进程数量:1 - Process Count: 1
#### 3.2 Octane 专用伪静态规则 #### 3.2 Octane-specific Rewrite Rules
```nginx ```nginx
location ~* \.(jpg|jpeg|png|gif|js|css|svg|woff2|woff|ttf|eot|wasm|json|ico)$ { location ~* \.(jpg|jpeg|png|gif|js|css|svg|woff2|woff|ttf|eot|wasm|json|ico)$ {
} }
@@ -146,30 +146,65 @@ location ~ .* {
} }
``` ```
## 维护指南 ## Maintenance Guide
### 版本更新 ### Version Updates
```bash ```bash
# 进入网站目录 # Enter site directory
cd /www/wwwroot/your-domain cd /www/wwwroot/your-domain
# 执行更新脚本 # Execute update script
git fetch --all && git reset --hard origin/master && git pull origin master git fetch --all && git reset --hard origin/master && git pull origin master
sh update.sh sh update.sh
# 如果启用了 Octane,请重启守护进程 # If Octane is enabled, restart the daemon process
# aaPanel > 应用商店 > 工具 > Supervisor > 重启 Octane # aaPanel > App Store > Tools > Supervisor > Restart Octane
``` ```
### 日常维护 ### Routine Maintenance
- 定期检查日志 - Regular log checking
- 监控系统资源使用情况 - Monitor system resource usage
- 定期备份数据库和配置文件 - Regular backup of database and configuration files
## 故障排查 ## Troubleshooting
### 常见问题 ### Common Issues
1. 修改后台路径后,需要重启服务才会生效 1. **Empty Admin Dashboard**: If the admin panel is blank, run `git submodule update --init --recursive --force` to restore the theme files.
2. 启用 Octane 后,任何代码变更都需要重启才会生效 2. Changes to admin path require service restart to take effect
3. PHP 扩展安装失败时,请检查 PHP 版本是否正确 3. Any code changes after enabling Octane require restart to take effect
4. 数据库连接失败时,请检查数据库配置和权限 3. When PHP extension installation fails, check if PHP version is correct
4. For database connection failures, check database configuration and permissions
## Enable WebSocket Real-time Sync (Optional)
WebSocket enables real-time synchronization of configurations and user changes to nodes.
### 1. Start WS Server
Add a WebSocket daemon process in aaPanel Supervisor:
- Name: `Xboard-WS`
- Run User: `www`
- Running Directory: Site directory
- Start Command: `php artisan ws-server start`
- Process Count: 1
### 2. Configure Nginx
Add the WebSocket location **before** the main `location ^~ /` block in your site's Nginx configuration:
```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;
}
```
### 3. Restart Services
Restart the Octane and WS Server processes in Supervisor.
> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side.
+1
View File
@@ -3,6 +3,7 @@
rm -rf composer.phar rm -rf composer.phar
wget https://github.com/composer/composer/releases/latest/download/composer.phar -O composer.phar wget https://github.com/composer/composer/releases/latest/download/composer.phar -O composer.phar
php composer.phar install -vvv php composer.phar install -vvv
git submodule update --init --recursive --force
php artisan xboard:install php artisan xboard:install
if [ -f "/etc/init.d/bt" ] || [ -f "/.dockerenv" ]; then if [ -f "/etc/init.d/bt" ] || [ -f "/.dockerenv" ]; then
Submodule public/assets/admin added at 9d13978a61
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-32
View File
@@ -1,32 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link rel="icon" type="image/png" href="/images/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadcn Admin</title>
<meta
name="description"
content="Admin Dashboard UI built with Shadcn and Vite."
/>
<script>
window.settings = {
base_url: 'http://127.0.0.1:8000',
title: 'Xboard',
version: '1.0.0',
logo: 'https://xboard.io/i6mages/logo.png',
secure_path: '/afbced4e',
}
</script>
<script src="./locales/en-US.js"></script>
<script src="./locales/zh-CN.js"></script>
<script src="./locales/ko-KR.js"></script>
<script type="module" crossorigin src="./assets/index.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index.css">
<link rel="stylesheet" crossorigin href="./assets/vendor.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+226 -522
View File
@@ -1,233 +1,94 @@
# port: 7890
# socks-port: 7891
# redir-port: 7892
# tproxy-port: 7893
mixed-port: 7890 mixed-port: 7890
allow-lan: true allow-lan: true
bind-address: "*" bind-address: "*"
mode: rule mode: rule
log-level: info log-level: info
external-controller: 127.0.0.1:9090 external-controller: 127.0.0.1:9090
unified-delay: true
tcp-concurrent: true
dns: dns:
enable: true enable: true
# listen: 0.0.0.0:53
ipv6: false ipv6: false
default-nameserver: default-nameserver:
- 223.5.5.5 - 223.5.5.5
- 119.29.29.29 - 119.29.29.29
enhanced-mode: fake-ip enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16 fake-ip-range: 198.18.0.1/16
use-hosts: true use-hosts: true
nameserver-policy:
"+.google.com": "https://dns.cloudflare.com/dns-query"
"+.googleapis.com": "https://dns.cloudflare.com/dns-query"
"+.googleapis.cn": "https://dns.cloudflare.com/dns-query"
"+.googlevideo.com": "https://dns.cloudflare.com/dns-query"
"+.gstatic.com": "https://dns.cloudflare.com/dns-query"
"+.youtube.com": "https://dns.cloudflare.com/dns-query"
"+.youtu.be": "https://dns.cloudflare.com/dns-query"
"+.facebook.com": "https://dns.cloudflare.com/dns-query"
"+.twitter.com": "https://dns.cloudflare.com/dns-query"
"+.x.com": "https://dns.cloudflare.com/dns-query"
"+.github.com": "https://dns.cloudflare.com/dns-query"
"+.githubusercontent.com": "https://dns.cloudflare.com/dns-query"
"+.openai.com": "https://dns.cloudflare.com/dns-query"
"+.chatgpt.com": "https://dns.cloudflare.com/dns-query"
"+.anthropic.com": "https://dns.cloudflare.com/dns-query"
nameserver: nameserver:
- https://doh.pub/dns-query - https://doh.pub/dns-query
- https://dns.alidns.com/dns-query - https://dns.alidns.com/dns-query
- tls://dot.pub:853
- tls://dns.alidns.com:853
fallback: fallback:
- https://doh-pure.onedns.net/dns-query - https://dns.cloudflare.com/dns-query
- https://ada.openbld.net/dns-query - https://dns.google/dns-query
- https://223.5.5.5/dns-query - tls://1.1.1.1:853
- https://223.6.6.6/dns-query - tls://8.8.8.8:853
fallback-filter: fallback-filter:
geoip: true geoip: true
geoip-code: CN
ipcidr: ipcidr:
- 0.0.0.0/8
- 10.0.0.0/8
- 100.64.0.0/10
- 127.0.0.0/8
- 169.254.0.0/16
- 172.16.0.0/12
- 192.168.0.0/16
- 224.0.0.0/4
- 240.0.0.0/4 - 240.0.0.0/4
- 0.0.0.0/32 domain:
- "+.google.com"
- "+.facebook.com"
- "+.youtube.com"
- "+.githubusercontent.com"
- "+.googlevideo.com"
- "+.googleapis.cn"
fake-ip-filter:
- "*.lan"
- "*.local"
- "*.localhost"
- "*.test"
- localhost.ptlogin2.qq.com
- "+.stun.*.*"
- "+.stun.*.*.*"
- "+.stun.*.*.*.*"
- "lens.l.google.com"
- "*.srv.nintendo.net"
- "+.stun.playstation.net"
- "xbox.*.*.microsoft.com"
- "*.*.xboxlive.com"
- "+.msftncsi.com"
- "+.msftconnecttest.com"
proxies: proxies:
proxy-groups: proxy-groups:
- { name: "$app_name", type: select, proxies: ["自动选择", "故障转移"] } - { name: "$app_name", type: select, proxies: ["自动选择", "故障转移", "DIRECT"] }
- { name: "自动选择", type: url-test, proxies: [], url: "http://www.gstatic.com/generate_204", interval: 86400 } - { name: "自动选择", type: url-test, proxies: [], url: "http://www.gstatic.com/generate_204", interval: 300, tolerance: 50 }
- { name: "故障转移", type: fallback, proxies: [], url: "http://www.gstatic.com/generate_204", interval: 7200 } - { name: "故障转移", type: fallback, proxies: [], url: "http://www.gstatic.com/generate_204", interval: 300 }
rules: rules:
# 自定义规则 # Custom
## 您可以在此处插入您补充的自定义规则(请注意保持缩进) # Ad blocking
# Google 中国服务
- DOMAIN-SUFFIX,services.googleapis.cn,$app_name
- DOMAIN-SUFFIX,xn--ngstr-lra8j.com,$app_name
# Apple
- DOMAIN,safebrowsing.urlsec.qq.com,DIRECT # 如果您并不信任此服务提供商或防止其下载消耗过多带宽资源,可以进入 Safari 设置,关闭 Fraudulent Website Warning 功能,并使用 REJECT 策略。
- DOMAIN,safebrowsing.googleapis.com,DIRECT # 如果您并不信任此服务提供商或防止其下载消耗过多带宽资源,可以进入 Safari 设置,关闭 Fraudulent Website Warning 功能,并使用 REJECT 策略。
- DOMAIN,developer.apple.com,$app_name
- DOMAIN-SUFFIX,digicert.com,$app_name
- DOMAIN,ocsp.apple.com,$app_name
- DOMAIN,ocsp.comodoca.com,$app_name
- DOMAIN,ocsp.usertrust.com,$app_name
- DOMAIN,ocsp.sectigo.com,$app_name
- DOMAIN,ocsp.verisign.net,$app_name
- DOMAIN-SUFFIX,apple-dns.net,$app_name
- DOMAIN,testflight.apple.com,$app_name
- DOMAIN,sandbox.itunes.apple.com,$app_name
- DOMAIN,itunes.apple.com,$app_name
- DOMAIN-SUFFIX,apps.apple.com,$app_name
- DOMAIN-SUFFIX,blobstore.apple.com,$app_name
- DOMAIN,cvws.icloud-content.com,$app_name
- DOMAIN-SUFFIX,mzstatic.com,DIRECT
- DOMAIN-SUFFIX,itunes.apple.com,DIRECT
- DOMAIN-SUFFIX,icloud.com,DIRECT
- DOMAIN-SUFFIX,icloud-content.com,DIRECT
- DOMAIN-SUFFIX,me.com,DIRECT
- DOMAIN-SUFFIX,aaplimg.com,DIRECT
- DOMAIN-SUFFIX,cdn20.com,DIRECT
- DOMAIN-SUFFIX,cdn-apple.com,DIRECT
- DOMAIN-SUFFIX,akadns.net,DIRECT
- DOMAIN-SUFFIX,akamaiedge.net,DIRECT
- DOMAIN-SUFFIX,edgekey.net,DIRECT
- DOMAIN-SUFFIX,mwcloudcdn.com,DIRECT
- DOMAIN-SUFFIX,mwcname.com,DIRECT
- DOMAIN-SUFFIX,apple.com,DIRECT
- DOMAIN-SUFFIX,apple-cloudkit.com,DIRECT
- DOMAIN-SUFFIX,apple-mapkit.com,DIRECT
# - DOMAIN,e.crashlytics.com,REJECT //注释此选项有助于大多数App开发者分析崩溃信息;如果您拒绝一切崩溃数据统计、搜集,请取消 # 注释。
# 国内网站
- DOMAIN-SUFFIX,126.com,DIRECT
- DOMAIN-SUFFIX,126.net,DIRECT
- DOMAIN-SUFFIX,127.net,DIRECT
- DOMAIN-SUFFIX,163.com,DIRECT
- DOMAIN-SUFFIX,360buyimg.com,DIRECT
- DOMAIN-SUFFIX,36kr.com,DIRECT
- DOMAIN-SUFFIX,acfun.tv,DIRECT
- DOMAIN-SUFFIX,air-matters.com,DIRECT
- DOMAIN-SUFFIX,aixifan.com,DIRECT
- DOMAIN-KEYWORD,alicdn,DIRECT
- DOMAIN-KEYWORD,alipay,DIRECT
- DOMAIN-KEYWORD,taobao,DIRECT
- DOMAIN-SUFFIX,amap.com,DIRECT
- DOMAIN-SUFFIX,autonavi.com,DIRECT
- DOMAIN-KEYWORD,baidu,DIRECT
- DOMAIN-SUFFIX,bdimg.com,DIRECT
- DOMAIN-SUFFIX,bdstatic.com,DIRECT
- DOMAIN-SUFFIX,bilibili.com,DIRECT
- DOMAIN-SUFFIX,bilivideo.com,DIRECT
- DOMAIN-SUFFIX,caiyunapp.com,DIRECT
- DOMAIN-SUFFIX,clouddn.com,DIRECT
- DOMAIN-SUFFIX,cnbeta.com,DIRECT
- DOMAIN-SUFFIX,cnbetacdn.com,DIRECT
- DOMAIN-SUFFIX,cootekservice.com,DIRECT
- DOMAIN-SUFFIX,csdn.net,DIRECT
- DOMAIN-SUFFIX,ctrip.com,DIRECT
- DOMAIN-SUFFIX,dgtle.com,DIRECT
- DOMAIN-SUFFIX,dianping.com,DIRECT
- DOMAIN-SUFFIX,douban.com,DIRECT
- DOMAIN-SUFFIX,doubanio.com,DIRECT
- DOMAIN-SUFFIX,duokan.com,DIRECT
- DOMAIN-SUFFIX,easou.com,DIRECT
- DOMAIN-SUFFIX,ele.me,DIRECT
- DOMAIN-SUFFIX,feng.com,DIRECT
- DOMAIN-SUFFIX,fir.im,DIRECT
- DOMAIN-SUFFIX,frdic.com,DIRECT
- DOMAIN-SUFFIX,g-cores.com,DIRECT
- DOMAIN-SUFFIX,godic.net,DIRECT
- DOMAIN-SUFFIX,gtimg.com,DIRECT
- DOMAIN,cdn.hockeyapp.net,DIRECT
- DOMAIN-SUFFIX,hongxiu.com,DIRECT
- DOMAIN-SUFFIX,hxcdn.net,DIRECT
- DOMAIN-SUFFIX,iciba.com,DIRECT
- DOMAIN-SUFFIX,ifeng.com,DIRECT
- DOMAIN-SUFFIX,ifengimg.com,DIRECT
- DOMAIN-SUFFIX,ipip.net,DIRECT
- DOMAIN-SUFFIX,iqiyi.com,DIRECT
- DOMAIN-SUFFIX,jd.com,DIRECT
- DOMAIN-SUFFIX,jianshu.com,DIRECT
- DOMAIN-SUFFIX,knewone.com,DIRECT
- DOMAIN-SUFFIX,le.com,DIRECT
- DOMAIN-SUFFIX,lecloud.com,DIRECT
- DOMAIN-SUFFIX,lemicp.com,DIRECT
- DOMAIN-SUFFIX,licdn.com,DIRECT
- DOMAIN-SUFFIX,luoo.net,DIRECT
- DOMAIN-SUFFIX,meituan.com,DIRECT
- DOMAIN-SUFFIX,meituan.net,DIRECT
- DOMAIN-SUFFIX,mi.com,DIRECT
- DOMAIN-SUFFIX,miaopai.com,DIRECT
- DOMAIN-SUFFIX,microsoft.com,DIRECT
- DOMAIN-SUFFIX,microsoftonline.com,DIRECT
- DOMAIN-SUFFIX,miui.com,DIRECT
- DOMAIN-SUFFIX,miwifi.com,DIRECT
- DOMAIN-SUFFIX,mob.com,DIRECT
- DOMAIN-SUFFIX,netease.com,DIRECT
- DOMAIN-SUFFIX,office.com,DIRECT
- DOMAIN-SUFFIX,office365.com,DIRECT
- DOMAIN-KEYWORD,officecdn,DIRECT
- DOMAIN-SUFFIX,oschina.net,DIRECT
- DOMAIN-SUFFIX,ppsimg.com,DIRECT
- DOMAIN-SUFFIX,pstatp.com,DIRECT
- DOMAIN-SUFFIX,qcloud.com,DIRECT
- DOMAIN-SUFFIX,qdaily.com,DIRECT
- DOMAIN-SUFFIX,qdmm.com,DIRECT
- DOMAIN-SUFFIX,qhimg.com,DIRECT
- DOMAIN-SUFFIX,qhres.com,DIRECT
- DOMAIN-SUFFIX,qidian.com,DIRECT
- DOMAIN-SUFFIX,qihucdn.com,DIRECT
- DOMAIN-SUFFIX,qiniu.com,DIRECT
- DOMAIN-SUFFIX,qiniucdn.com,DIRECT
- DOMAIN-SUFFIX,qiyipic.com,DIRECT
- DOMAIN-SUFFIX,qq.com,DIRECT
- DOMAIN-SUFFIX,qqurl.com,DIRECT
- DOMAIN-SUFFIX,rarbg.to,DIRECT
- DOMAIN-SUFFIX,ruguoapp.com,DIRECT
- DOMAIN-SUFFIX,segmentfault.com,DIRECT
- DOMAIN-SUFFIX,sinaapp.com,DIRECT
- DOMAIN-SUFFIX,smzdm.com,DIRECT
- DOMAIN-SUFFIX,snapdrop.net,DIRECT
- DOMAIN-SUFFIX,sogou.com,DIRECT
- DOMAIN-SUFFIX,sogoucdn.com,DIRECT
- DOMAIN-SUFFIX,sohu.com,DIRECT
- DOMAIN-SUFFIX,soku.com,DIRECT
- DOMAIN-SUFFIX,speedtest.net,DIRECT
- DOMAIN-SUFFIX,sspai.com,DIRECT
- DOMAIN-SUFFIX,suning.com,DIRECT
- DOMAIN-SUFFIX,taobao.com,DIRECT
- DOMAIN-SUFFIX,tencent.com,DIRECT
- DOMAIN-SUFFIX,tenpay.com,DIRECT
- DOMAIN-SUFFIX,tianyancha.com,DIRECT
- DOMAIN-SUFFIX,tmall.com,DIRECT
- DOMAIN-SUFFIX,tudou.com,DIRECT
- DOMAIN-SUFFIX,umetrip.com,DIRECT
- DOMAIN-SUFFIX,upaiyun.com,DIRECT
- DOMAIN-SUFFIX,upyun.com,DIRECT
- DOMAIN-SUFFIX,veryzhun.com,DIRECT
- DOMAIN-SUFFIX,weather.com,DIRECT
- DOMAIN-SUFFIX,weibo.com,DIRECT
- DOMAIN-SUFFIX,xiami.com,DIRECT
- DOMAIN-SUFFIX,xiami.net,DIRECT
- DOMAIN-SUFFIX,xiaomicp.com,DIRECT
- DOMAIN-SUFFIX,ximalaya.com,DIRECT
- DOMAIN-SUFFIX,xmcdn.com,DIRECT
- DOMAIN-SUFFIX,xunlei.com,DIRECT
- DOMAIN-SUFFIX,yhd.com,DIRECT
- DOMAIN-SUFFIX,yihaodianimg.com,DIRECT
- DOMAIN-SUFFIX,yinxiang.com,DIRECT
- DOMAIN-SUFFIX,ykimg.com,DIRECT
- DOMAIN-SUFFIX,youdao.com,DIRECT
- DOMAIN-SUFFIX,youku.com,DIRECT
- DOMAIN-SUFFIX,zealer.com,DIRECT
- DOMAIN-SUFFIX,zhihu.com,DIRECT
- DOMAIN-SUFFIX,zhimg.com,DIRECT
- DOMAIN-SUFFIX,zimuzu.tv,DIRECT
- DOMAIN-SUFFIX,zoho.com,DIRECT
# 抗 DNS 污染
- DOMAIN-KEYWORD,amazon,$app_name
- DOMAIN-KEYWORD,google,$app_name
- DOMAIN-KEYWORD,gmail,$app_name
- DOMAIN-KEYWORD,youtube,$app_name
- DOMAIN-KEYWORD,facebook,$app_name
- DOMAIN-SUFFIX,fb.me,$app_name
- DOMAIN-SUFFIX,fbcdn.net,$app_name
- DOMAIN-KEYWORD,twitter,$app_name
- DOMAIN-KEYWORD,instagram,$app_name
- DOMAIN-KEYWORD,dropbox,$app_name
- DOMAIN-SUFFIX,twimg.com,$app_name
- DOMAIN-KEYWORD,blogspot,$app_name
- DOMAIN-SUFFIX,youtu.be,$app_name
- DOMAIN-KEYWORD,whatsapp,$app_name
# 常见广告域名屏蔽
- DOMAIN-KEYWORD,admarvel,REJECT - DOMAIN-KEYWORD,admarvel,REJECT
- DOMAIN-KEYWORD,admaster,REJECT - DOMAIN-KEYWORD,admaster,REJECT
- DOMAIN-KEYWORD,adsage,REJECT - DOMAIN-KEYWORD,adsage,REJECT
@@ -235,348 +96,191 @@ rules:
- DOMAIN-KEYWORD,adsrvmedia,REJECT - DOMAIN-KEYWORD,adsrvmedia,REJECT
- DOMAIN-KEYWORD,adwords,REJECT - DOMAIN-KEYWORD,adwords,REJECT
- DOMAIN-KEYWORD,adservice,REJECT - DOMAIN-KEYWORD,adservice,REJECT
- DOMAIN-SUFFIX,appsflyer.com,REJECT
- DOMAIN-KEYWORD,domob,REJECT - DOMAIN-KEYWORD,domob,REJECT
- DOMAIN-SUFFIX,doubleclick.net,REJECT
- DOMAIN-KEYWORD,duomeng,REJECT - DOMAIN-KEYWORD,duomeng,REJECT
- DOMAIN-KEYWORD,dwtrack,REJECT - DOMAIN-KEYWORD,dwtrack,REJECT
- DOMAIN-KEYWORD,guanggao,REJECT - DOMAIN-KEYWORD,guanggao,REJECT
- DOMAIN-KEYWORD,lianmeng,REJECT - DOMAIN-KEYWORD,lianmeng,REJECT
- DOMAIN-SUFFIX,mmstat.com,REJECT
- DOMAIN-KEYWORD,mopub,REJECT
- DOMAIN-KEYWORD,omgmta,REJECT - DOMAIN-KEYWORD,omgmta,REJECT
- DOMAIN-KEYWORD,openx,REJECT - DOMAIN-KEYWORD,openx,REJECT
- DOMAIN-KEYWORD,partnerad,REJECT - DOMAIN-KEYWORD,partnerad,REJECT
- DOMAIN-KEYWORD,pingfore,REJECT
- DOMAIN-KEYWORD,supersonicads,REJECT - DOMAIN-KEYWORD,supersonicads,REJECT
- DOMAIN-KEYWORD,uedas,REJECT
- DOMAIN-KEYWORD,umeng,REJECT - DOMAIN-KEYWORD,umeng,REJECT
- DOMAIN-KEYWORD,usage,REJECT
- DOMAIN-SUFFIX,vungle.com,REJECT
- DOMAIN-KEYWORD,wlmonitor,REJECT
- DOMAIN-KEYWORD,zjtoolbar,REJECT - DOMAIN-KEYWORD,zjtoolbar,REJECT
- DOMAIN-SUFFIX,appsflyer.com,REJECT
# 国外网站 - DOMAIN-SUFFIX,doubleclick.net,REJECT
- DOMAIN-SUFFIX,9to5mac.com,$app_name - DOMAIN-SUFFIX,mmstat.com,REJECT
- DOMAIN-SUFFIX,abpchina.org,$app_name # LAN
- DOMAIN-SUFFIX,adblockplus.org,$app_name - DOMAIN-SUFFIX,local,DIRECT
- DOMAIN-SUFFIX,adobe.com,$app_name - DOMAIN-SUFFIX,localhost,DIRECT
- DOMAIN-SUFFIX,akamaized.net,$app_name - IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- DOMAIN-SUFFIX,alfredapp.com,$app_name - IP-CIDR,17.0.0.0/8,DIRECT,no-resolve
- DOMAIN-SUFFIX,amplitude.com,$app_name - IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
- DOMAIN-SUFFIX,ampproject.org,$app_name - IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- DOMAIN-SUFFIX,android.com,$app_name - IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- DOMAIN-SUFFIX,angularjs.org,$app_name - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- DOMAIN-SUFFIX,aolcdn.com,$app_name - IP-CIDR,198.18.0.0/16,DIRECT,no-resolve
- DOMAIN-SUFFIX,apkpure.com,$app_name - IP-CIDR,224.0.0.0/4,DIRECT,no-resolve
- DOMAIN-SUFFIX,appledaily.com,$app_name - IP-CIDR6,::1/128,DIRECT,no-resolve
- DOMAIN-SUFFIX,appshopper.com,$app_name - IP-CIDR6,fc00::/7,DIRECT,no-resolve
- DOMAIN-SUFFIX,appspot.com,$app_name - IP-CIDR6,fe80::/10,DIRECT,no-resolve
- DOMAIN-SUFFIX,arcgis.com,$app_name # Apple (App Store via proxy for foreign regions)
- DOMAIN-SUFFIX,archive.org,$app_name - DOMAIN-SUFFIX,apps.apple.com,$app_name
- DOMAIN-SUFFIX,armorgames.com,$app_name - DOMAIN-SUFFIX,itunes.apple.com,$app_name
- DOMAIN-SUFFIX,aspnetcdn.com,$app_name - DOMAIN-SUFFIX,blobstore.apple.com,$app_name
- DOMAIN-SUFFIX,att.com,$app_name - DOMAIN,safebrowsing.urlsec.qq.com,DIRECT
- DOMAIN-SUFFIX,awsstatic.com,$app_name - DOMAIN-SUFFIX,apple.com,DIRECT
- DOMAIN-SUFFIX,azureedge.net,$app_name - DOMAIN-SUFFIX,apple-cloudkit.com,DIRECT
- DOMAIN-SUFFIX,azurewebsites.net,$app_name - DOMAIN-SUFFIX,icloud.com,DIRECT
- DOMAIN-SUFFIX,bing.com,$app_name - DOMAIN-SUFFIX,icloud-content.com,DIRECT
- DOMAIN-SUFFIX,bintray.com,$app_name - DOMAIN-SUFFIX,mzstatic.com,DIRECT
- DOMAIN-SUFFIX,bit.com,$app_name - DOMAIN-SUFFIX,aaplimg.com,DIRECT
- DOMAIN-SUFFIX,bit.ly,$app_name - DOMAIN-SUFFIX,cdn-apple.com,DIRECT
- DOMAIN-SUFFIX,bitbucket.org,$app_name - DOMAIN-SUFFIX,akadns.net,DIRECT
- DOMAIN-SUFFIX,bjango.com,$app_name # China direct (KEYWORD)
- DOMAIN-SUFFIX,bkrtx.com,$app_name - DOMAIN-KEYWORD,baidu,DIRECT
- DOMAIN-SUFFIX,blog.com,$app_name - DOMAIN-KEYWORD,alibaba,DIRECT
- DOMAIN-SUFFIX,blogcdn.com,$app_name - DOMAIN-KEYWORD,alicdn,DIRECT
- DOMAIN-SUFFIX,blogger.com,$app_name - DOMAIN-KEYWORD,alipay,DIRECT
- DOMAIN-SUFFIX,blogsmithmedia.com,$app_name - DOMAIN-KEYWORD,taobao,DIRECT
- DOMAIN-SUFFIX,blogspot.com,$app_name - DOMAIN-KEYWORD,tencent,DIRECT
- DOMAIN-SUFFIX,blogspot.hk,$app_name - DOMAIN-KEYWORD,bilibili,DIRECT
- DOMAIN-SUFFIX,bloomberg.com,$app_name - DOMAIN-KEYWORD,weibo,DIRECT
- DOMAIN-SUFFIX,box.com,$app_name - DOMAIN-KEYWORD,douyin,DIRECT
- DOMAIN-SUFFIX,box.net,$app_name - DOMAIN-KEYWORD,bytedance,DIRECT
- DOMAIN-SUFFIX,cachefly.net,$app_name - DOMAIN-KEYWORD,xiaomi,DIRECT
- DOMAIN-SUFFIX,chromium.org,$app_name - DOMAIN-KEYWORD,huawei,DIRECT
- DOMAIN-SUFFIX,cl.ly,$app_name - DOMAIN-KEYWORD,netease,DIRECT
- DOMAIN-SUFFIX,cloudflare.com,$app_name - DOMAIN-KEYWORD,meituan,DIRECT
- DOMAIN-SUFFIX,cloudfront.net,$app_name - DOMAIN-KEYWORD,pinduoduo,DIRECT
- DOMAIN-SUFFIX,cloudmagic.com,$app_name - DOMAIN-KEYWORD,kuaishou,DIRECT
- DOMAIN-SUFFIX,cmail19.com,$app_name - DOMAIN-KEYWORD,jingdong,DIRECT
- DOMAIN-SUFFIX,cnet.com,$app_name - DOMAIN-KEYWORD,officecdn,DIRECT
- DOMAIN-SUFFIX,cocoapods.org,$app_name # China direct (SUFFIX)
- DOMAIN-SUFFIX,comodoca.com,$app_name - DOMAIN-SUFFIX,qq.com,DIRECT
- DOMAIN-SUFFIX,crashlytics.com,$app_name - DOMAIN-SUFFIX,weixin.com,DIRECT
- DOMAIN-SUFFIX,culturedcode.com,$app_name - DOMAIN-SUFFIX,wechat.com,DIRECT
- DOMAIN-SUFFIX,d.pr,$app_name - DOMAIN-SUFFIX,gtimg.com,DIRECT
- DOMAIN-SUFFIX,danilo.to,$app_name - DOMAIN-SUFFIX,qcloud.com,DIRECT
- DOMAIN-SUFFIX,dayone.me,$app_name - DOMAIN-SUFFIX,myqcloud.com,DIRECT
- DOMAIN-SUFFIX,db.tt,$app_name - DOMAIN-SUFFIX,qpic.cn,DIRECT
- DOMAIN-SUFFIX,deskconnect.com,$app_name - DOMAIN-SUFFIX,tenpay.com,DIRECT
- DOMAIN-SUFFIX,disq.us,$app_name - DOMAIN-SUFFIX,tmall.com,DIRECT
- DOMAIN-SUFFIX,disqus.com,$app_name - DOMAIN-SUFFIX,jd.com,DIRECT
- DOMAIN-SUFFIX,disquscdn.com,$app_name - DOMAIN-SUFFIX,360buyimg.com,DIRECT
- DOMAIN-SUFFIX,dnsimple.com,$app_name - DOMAIN-SUFFIX,iqiyi.com,DIRECT
- DOMAIN-SUFFIX,docker.com,$app_name - DOMAIN-SUFFIX,youku.com,DIRECT
- DOMAIN-SUFFIX,dribbble.com,$app_name - DOMAIN-SUFFIX,ykimg.com,DIRECT
- DOMAIN-SUFFIX,droplr.com,$app_name - DOMAIN-SUFFIX,tudou.com,DIRECT
- DOMAIN-SUFFIX,duckduckgo.com,$app_name - DOMAIN-SUFFIX,acfun.tv,DIRECT
- DOMAIN-SUFFIX,dueapp.com,$app_name - DOMAIN-SUFFIX,hdslb.com,DIRECT
- DOMAIN-SUFFIX,dytt8.net,$app_name - DOMAIN-SUFFIX,sohu.com,DIRECT
- DOMAIN-SUFFIX,edgecastcdn.net,$app_name - DOMAIN-SUFFIX,sogou.com,DIRECT
- DOMAIN-SUFFIX,edgekey.net,$app_name - DOMAIN-SUFFIX,zhihu.com,DIRECT
- DOMAIN-SUFFIX,edgesuite.net,$app_name - DOMAIN-SUFFIX,zhimg.com,DIRECT
- DOMAIN-SUFFIX,engadget.com,$app_name - DOMAIN-SUFFIX,douban.com,DIRECT
- DOMAIN-SUFFIX,entrust.net,$app_name - DOMAIN-SUFFIX,doubanio.com,DIRECT
- DOMAIN-SUFFIX,eurekavpt.com,$app_name - DOMAIN-SUFFIX,163.com,DIRECT
- DOMAIN-SUFFIX,evernote.com,$app_name - DOMAIN-SUFFIX,126.com,DIRECT
- DOMAIN-SUFFIX,fabric.io,$app_name - DOMAIN-SUFFIX,126.net,DIRECT
- DOMAIN-SUFFIX,fast.com,$app_name - DOMAIN-SUFFIX,127.net,DIRECT
- DOMAIN-SUFFIX,fastly.net,$app_name - DOMAIN-SUFFIX,yeah.net,DIRECT
- DOMAIN-SUFFIX,fc2.com,$app_name - DOMAIN-SUFFIX,sina.com,DIRECT
- DOMAIN-SUFFIX,feedburner.com,$app_name - DOMAIN-SUFFIX,sinaimg.cn,DIRECT
- DOMAIN-SUFFIX,feedly.com,$app_name - DOMAIN-SUFFIX,ximalaya.com,DIRECT
- DOMAIN-SUFFIX,feedsportal.com,$app_name - DOMAIN-SUFFIX,xmcdn.com,DIRECT
- DOMAIN-SUFFIX,fiftythree.com,$app_name - DOMAIN-SUFFIX,csdn.net,DIRECT
- DOMAIN-SUFFIX,firebaseio.com,$app_name - DOMAIN-SUFFIX,gitee.com,DIRECT
- DOMAIN-SUFFIX,flexibits.com,$app_name - DOMAIN-SUFFIX,jianshu.com,DIRECT
- DOMAIN-SUFFIX,flickr.com,$app_name - DOMAIN-SUFFIX,cnblogs.com,DIRECT
- DOMAIN-SUFFIX,flipboard.com,$app_name - DOMAIN-SUFFIX,oschina.net,DIRECT
- DOMAIN-SUFFIX,g.co,$app_name - DOMAIN-SUFFIX,ele.me,DIRECT
- DOMAIN-SUFFIX,gabia.net,$app_name - DOMAIN-SUFFIX,ctrip.com,DIRECT
- DOMAIN-SUFFIX,geni.us,$app_name - DOMAIN-SUFFIX,suning.com,DIRECT
- DOMAIN-SUFFIX,gfx.ms,$app_name - DOMAIN-SUFFIX,dianping.com,DIRECT
- DOMAIN-SUFFIX,ggpht.com,$app_name - DOMAIN-SUFFIX,amap.com,DIRECT
- DOMAIN-SUFFIX,ghostnoteapp.com,$app_name - DOMAIN-SUFFIX,autonavi.com,DIRECT
- DOMAIN-SUFFIX,git.io,$app_name - DOMAIN-SUFFIX,mi.com,DIRECT
- DOMAIN-SUFFIX,miui.com,DIRECT
- DOMAIN-SUFFIX,ifeng.com,DIRECT
- DOMAIN-SUFFIX,youdao.com,DIRECT
- DOMAIN-SUFFIX,iciba.com,DIRECT
- DOMAIN-SUFFIX,xunlei.com,DIRECT
- DOMAIN-SUFFIX,smzdm.com,DIRECT
- DOMAIN-SUFFIX,sspai.com,DIRECT
- DOMAIN-SUFFIX,36kr.com,DIRECT
- DOMAIN-SUFFIX,speedtest.net,DIRECT
- DOMAIN-SUFFIX,microsoft.com,DIRECT
- DOMAIN-SUFFIX,microsoftonline.com,DIRECT
- DOMAIN-SUFFIX,office.com,DIRECT
- DOMAIN-SUFFIX,office365.com,DIRECT
- DOMAIN-SUFFIX,windows.com,DIRECT
- DOMAIN-SUFFIX,windowsupdate.com,DIRECT
- DOMAIN-SUFFIX,live.com,DIRECT
- DOMAIN-SUFFIX,msn.com,DIRECT
- DOMAIN-SUFFIX,cn,DIRECT
- DOMAIN-KEYWORD,-cn,DIRECT
# Blocked services (KEYWORD)
- DOMAIN-KEYWORD,google,$app_name
- DOMAIN-KEYWORD,gmail,$app_name
- DOMAIN-KEYWORD,youtube,$app_name
- DOMAIN-KEYWORD,facebook,$app_name
- DOMAIN-KEYWORD,twitter,$app_name
- DOMAIN-KEYWORD,instagram,$app_name
- DOMAIN-KEYWORD,whatsapp,$app_name
- DOMAIN-KEYWORD,telegram,$app_name
- DOMAIN-KEYWORD,github,$app_name - DOMAIN-KEYWORD,github,$app_name
- DOMAIN-SUFFIX,globalsign.com,$app_name - DOMAIN-KEYWORD,blogspot,$app_name
- DOMAIN-SUFFIX,gmodules.com,$app_name - DOMAIN-KEYWORD,dropbox,$app_name
- DOMAIN-SUFFIX,godaddy.com,$app_name - DOMAIN-KEYWORD,wikipedia,$app_name
- DOMAIN-SUFFIX,golang.org,$app_name - DOMAIN-KEYWORD,pinterest,$app_name
- DOMAIN-SUFFIX,gongm.in,$app_name - DOMAIN-KEYWORD,discord,$app_name
- DOMAIN-SUFFIX,goo.gl,$app_name - DOMAIN-KEYWORD,openai,$app_name
- DOMAIN-SUFFIX,goodreaders.com,$app_name - DOMAIN-KEYWORD,anthropic,$app_name
- DOMAIN-SUFFIX,goodreads.com,$app_name - DOMAIN-KEYWORD,netflix,$app_name
- DOMAIN-SUFFIX,gravatar.com,$app_name - DOMAIN-KEYWORD,spotify,$app_name
- DOMAIN-SUFFIX,gstatic.com,$app_name - DOMAIN-KEYWORD,amazon,$app_name
- DOMAIN-SUFFIX,gvt0.com,$app_name # Blocked services (SUFFIX)
- DOMAIN-SUFFIX,hockeyapp.net,$app_name - DOMAIN-SUFFIX,t.co,$app_name
- DOMAIN-SUFFIX,hotmail.com,$app_name - DOMAIN-SUFFIX,x.com,$app_name
- DOMAIN-SUFFIX,icons8.com,$app_name - DOMAIN-SUFFIX,twimg.com,$app_name
- DOMAIN-SUFFIX,ifixit.com,$app_name - DOMAIN-SUFFIX,fb.me,$app_name
- DOMAIN-SUFFIX,ift.tt,$app_name - DOMAIN-SUFFIX,fbcdn.net,$app_name
- DOMAIN-SUFFIX,ifttt.com,$app_name - DOMAIN-SUFFIX,youtu.be,$app_name
- DOMAIN-SUFFIX,iherb.com,$app_name
- DOMAIN-SUFFIX,imageshack.us,$app_name
- DOMAIN-SUFFIX,img.ly,$app_name
- DOMAIN-SUFFIX,imgur.com,$app_name
- DOMAIN-SUFFIX,imore.com,$app_name
- DOMAIN-SUFFIX,instapaper.com,$app_name
- DOMAIN-SUFFIX,ipn.li,$app_name
- DOMAIN-SUFFIX,is.gd,$app_name
- DOMAIN-SUFFIX,issuu.com,$app_name
- DOMAIN-SUFFIX,itgonglun.com,$app_name
- DOMAIN-SUFFIX,itun.es,$app_name
- DOMAIN-SUFFIX,ixquick.com,$app_name
- DOMAIN-SUFFIX,j.mp,$app_name
- DOMAIN-SUFFIX,js.revsci.net,$app_name
- DOMAIN-SUFFIX,jshint.com,$app_name
- DOMAIN-SUFFIX,jtvnw.net,$app_name
- DOMAIN-SUFFIX,justgetflux.com,$app_name
- DOMAIN-SUFFIX,kat.cr,$app_name
- DOMAIN-SUFFIX,klip.me,$app_name
- DOMAIN-SUFFIX,libsyn.com,$app_name
- DOMAIN-SUFFIX,linkedin.com,$app_name
- DOMAIN-SUFFIX,line-apps.com,$app_name
- DOMAIN-SUFFIX,linode.com,$app_name
- DOMAIN-SUFFIX,lithium.com,$app_name
- DOMAIN-SUFFIX,littlehj.com,$app_name
- DOMAIN-SUFFIX,live.com,$app_name
- DOMAIN-SUFFIX,live.net,$app_name
- DOMAIN-SUFFIX,livefilestore.com,$app_name
- DOMAIN-SUFFIX,llnwd.net,$app_name
- DOMAIN-SUFFIX,macid.co,$app_name
- DOMAIN-SUFFIX,macromedia.com,$app_name
- DOMAIN-SUFFIX,macrumors.com,$app_name
- DOMAIN-SUFFIX,mashable.com,$app_name
- DOMAIN-SUFFIX,mathjax.org,$app_name
- DOMAIN-SUFFIX,medium.com,$app_name
- DOMAIN-SUFFIX,mega.co.nz,$app_name
- DOMAIN-SUFFIX,mega.nz,$app_name
- DOMAIN-SUFFIX,megaupload.com,$app_name
- DOMAIN-SUFFIX,microsofttranslator.com,$app_name
- DOMAIN-SUFFIX,mindnode.com,$app_name
- DOMAIN-SUFFIX,mobile01.com,$app_name
- DOMAIN-SUFFIX,modmyi.com,$app_name
- DOMAIN-SUFFIX,msedge.net,$app_name
- DOMAIN-SUFFIX,myfontastic.com,$app_name
- DOMAIN-SUFFIX,name.com,$app_name
- DOMAIN-SUFFIX,nextmedia.com,$app_name
- DOMAIN-SUFFIX,nsstatic.net,$app_name
- DOMAIN-SUFFIX,nssurge.com,$app_name
- DOMAIN-SUFFIX,nyt.com,$app_name
- DOMAIN-SUFFIX,nytimes.com,$app_name
- DOMAIN-SUFFIX,omnigroup.com,$app_name
- DOMAIN-SUFFIX,onedrive.com,$app_name
- DOMAIN-SUFFIX,onenote.com,$app_name
- DOMAIN-SUFFIX,ooyala.com,$app_name
- DOMAIN-SUFFIX,openvpn.net,$app_name
- DOMAIN-SUFFIX,openwrt.org,$app_name
- DOMAIN-SUFFIX,orkut.com,$app_name
- DOMAIN-SUFFIX,osxdaily.com,$app_name
- DOMAIN-SUFFIX,outlook.com,$app_name
- DOMAIN-SUFFIX,ow.ly,$app_name
- DOMAIN-SUFFIX,paddleapi.com,$app_name
- DOMAIN-SUFFIX,parallels.com,$app_name
- DOMAIN-SUFFIX,parse.com,$app_name
- DOMAIN-SUFFIX,pdfexpert.com,$app_name
- DOMAIN-SUFFIX,periscope.tv,$app_name
- DOMAIN-SUFFIX,pinboard.in,$app_name
- DOMAIN-SUFFIX,pinterest.com,$app_name
- DOMAIN-SUFFIX,pixelmator.com,$app_name
- DOMAIN-SUFFIX,pixiv.net,$app_name
- DOMAIN-SUFFIX,playpcesor.com,$app_name
- DOMAIN-SUFFIX,playstation.com,$app_name
- DOMAIN-SUFFIX,playstation.com.hk,$app_name
- DOMAIN-SUFFIX,playstation.net,$app_name
- DOMAIN-SUFFIX,playstationnetwork.com,$app_name
- DOMAIN-SUFFIX,pushwoosh.com,$app_name
- DOMAIN-SUFFIX,rime.im,$app_name
- DOMAIN-SUFFIX,servebom.com,$app_name
- DOMAIN-SUFFIX,sfx.ms,$app_name
- DOMAIN-SUFFIX,shadowsocks.org,$app_name
- DOMAIN-SUFFIX,sharethis.com,$app_name
- DOMAIN-SUFFIX,shazam.com,$app_name
- DOMAIN-SUFFIX,skype.com,$app_name
- DOMAIN-SUFFIX,smartdns$app_name.com,$app_name
- DOMAIN-SUFFIX,smartmailcloud.com,$app_name
- DOMAIN-SUFFIX,sndcdn.com,$app_name
- DOMAIN-SUFFIX,sony.com,$app_name
- DOMAIN-SUFFIX,soundcloud.com,$app_name
- DOMAIN-SUFFIX,sourceforge.net,$app_name
- DOMAIN-SUFFIX,spotify.com,$app_name
- DOMAIN-SUFFIX,squarespace.com,$app_name
- DOMAIN-SUFFIX,sstatic.net,$app_name
- DOMAIN-SUFFIX,st.luluku.pw,$app_name
- DOMAIN-SUFFIX,stackoverflow.com,$app_name
- DOMAIN-SUFFIX,startpage.com,$app_name
- DOMAIN-SUFFIX,staticflickr.com,$app_name
- DOMAIN-SUFFIX,steamcommunity.com,$app_name
- DOMAIN-SUFFIX,symauth.com,$app_name
- DOMAIN-SUFFIX,symcb.com,$app_name
- DOMAIN-SUFFIX,symcd.com,$app_name
- DOMAIN-SUFFIX,tapbots.com,$app_name
- DOMAIN-SUFFIX,tapbots.net,$app_name
- DOMAIN-SUFFIX,tdesktop.com,$app_name
- DOMAIN-SUFFIX,techcrunch.com,$app_name
- DOMAIN-SUFFIX,techsmith.com,$app_name
- DOMAIN-SUFFIX,thepiratebay.org,$app_name
- DOMAIN-SUFFIX,theverge.com,$app_name
- DOMAIN-SUFFIX,time.com,$app_name
- DOMAIN-SUFFIX,timeinc.net,$app_name
- DOMAIN-SUFFIX,tiny.cc,$app_name
- DOMAIN-SUFFIX,tinypic.com,$app_name
- DOMAIN-SUFFIX,tmblr.co,$app_name
- DOMAIN-SUFFIX,todoist.com,$app_name
- DOMAIN-SUFFIX,trello.com,$app_name
- DOMAIN-SUFFIX,trustasiassl.com,$app_name
- DOMAIN-SUFFIX,tumblr.co,$app_name
- DOMAIN-SUFFIX,tumblr.com,$app_name
- DOMAIN-SUFFIX,tweetdeck.com,$app_name
- DOMAIN-SUFFIX,tweetmarker.net,$app_name
- DOMAIN-SUFFIX,twitch.tv,$app_name
- DOMAIN-SUFFIX,txmblr.com,$app_name
- DOMAIN-SUFFIX,typekit.net,$app_name
- DOMAIN-SUFFIX,ubertags.com,$app_name
- DOMAIN-SUFFIX,ublock.org,$app_name
- DOMAIN-SUFFIX,ubnt.com,$app_name
- DOMAIN-SUFFIX,ulyssesapp.com,$app_name
- DOMAIN-SUFFIX,urchin.com,$app_name
- DOMAIN-SUFFIX,usertrust.com,$app_name
- DOMAIN-SUFFIX,v.gd,$app_name
- DOMAIN-SUFFIX,v2ex.com,$app_name
- DOMAIN-SUFFIX,vimeo.com,$app_name
- DOMAIN-SUFFIX,vimeocdn.com,$app_name
- DOMAIN-SUFFIX,vine.co,$app_name
- DOMAIN-SUFFIX,vivaldi.com,$app_name
- DOMAIN-SUFFIX,vox-cdn.com,$app_name
- DOMAIN-SUFFIX,vsco.co,$app_name
- DOMAIN-SUFFIX,vultr.com,$app_name
- DOMAIN-SUFFIX,w.org,$app_name
- DOMAIN-SUFFIX,w3schools.com,$app_name
- DOMAIN-SUFFIX,webtype.com,$app_name
- DOMAIN-SUFFIX,wikiwand.com,$app_name
- DOMAIN-SUFFIX,wikileaks.org,$app_name
- DOMAIN-SUFFIX,wikimedia.org,$app_name
- DOMAIN-SUFFIX,wikipedia.com,$app_name
- DOMAIN-SUFFIX,wikipedia.org,$app_name
- DOMAIN-SUFFIX,windows.com,$app_name
- DOMAIN-SUFFIX,windows.net,$app_name
- DOMAIN-SUFFIX,wire.com,$app_name
- DOMAIN-SUFFIX,wordpress.com,$app_name
- DOMAIN-SUFFIX,workflowy.com,$app_name
- DOMAIN-SUFFIX,wp.com,$app_name
- DOMAIN-SUFFIX,wsj.com,$app_name
- DOMAIN-SUFFIX,wsj.net,$app_name
- DOMAIN-SUFFIX,xda-developers.com,$app_name
- DOMAIN-SUFFIX,xeeno.com,$app_name
- DOMAIN-SUFFIX,xiti.com,$app_name
- DOMAIN-SUFFIX,yahoo.com,$app_name
- DOMAIN-SUFFIX,yimg.com,$app_name
- DOMAIN-SUFFIX,ying.com,$app_name
- DOMAIN-SUFFIX,yoyo.org,$app_name
- DOMAIN-SUFFIX,ytimg.com,$app_name - DOMAIN-SUFFIX,ytimg.com,$app_name
- DOMAIN-SUFFIX,gstatic.com,$app_name
# Telegram - DOMAIN-SUFFIX,ggpht.com,$app_name
- DOMAIN-SUFFIX,telegra.ph,$app_name - DOMAIN-SUFFIX,googlevideo.com,$app_name
- DOMAIN-SUFFIX,telegram.org,$app_name - DOMAIN-SUFFIX,v2ex.com,$app_name
- DOMAIN-SUFFIX,medium.com,$app_name
- DOMAIN-SUFFIX,reddit.com,$app_name
- DOMAIN-SUFFIX,redd.it,$app_name
- DOMAIN-SUFFIX,imgur.com,$app_name
- DOMAIN-SUFFIX,pixiv.net,$app_name
- DOMAIN-SUFFIX,nytimes.com,$app_name
- DOMAIN-SUFFIX,nyt.com,$app_name
- DOMAIN-SUFFIX,bbc.com,$app_name
- DOMAIN-SUFFIX,bbc.co.uk,$app_name
- DOMAIN-SUFFIX,steamcommunity.com,$app_name
- DOMAIN-SUFFIX,twitch.tv,$app_name
- DOMAIN-SUFFIX,vimeo.com,$app_name
- DOMAIN-SUFFIX,tumblr.com,$app_name
- DOMAIN-SUFFIX,linkedin.com,$app_name
- DOMAIN-SUFFIX,licdn.com,$app_name
- DOMAIN-SUFFIX,mega.nz,$app_name
- DOMAIN-SUFFIX,archive.org,$app_name
- DOMAIN-SUFFIX,wikimedia.org,$app_name
- DOMAIN-SUFFIX,soundcloud.com,$app_name
# Telegram IP
- IP-CIDR,91.108.4.0/22,$app_name,no-resolve - IP-CIDR,91.108.4.0/22,$app_name,no-resolve
- IP-CIDR,91.108.8.0/21,$app_name,no-resolve - IP-CIDR,91.108.8.0/21,$app_name,no-resolve
- IP-CIDR,91.108.12.0/22,$app_name,no-resolve
- IP-CIDR,91.108.16.0/22,$app_name,no-resolve - IP-CIDR,91.108.16.0/22,$app_name,no-resolve
- IP-CIDR,91.108.56.0/22,$app_name,no-resolve - IP-CIDR,91.108.56.0/22,$app_name,no-resolve
- IP-CIDR,149.154.160.0/20,$app_name,no-resolve - IP-CIDR,149.154.160.0/20,$app_name,no-resolve
- IP-CIDR6,2001:67c:4e8::/48,$app_name,no-resolve - IP-CIDR6,2001:67c:4e8::/48,$app_name,no-resolve
- IP-CIDR6,2001:b28:f23d::/48,$app_name,no-resolve - IP-CIDR6,2001:b28:f23d::/48,$app_name,no-resolve
- IP-CIDR6,2001:b28:f23f::/48,$app_name,no-resolve - IP-CIDR6,2001:b28:f23f::/48,$app_name,no-resolve
# Fallback
# Google 中国服务 services.googleapis.cn
- IP-CIDR,120.232.181.162/32,$app_name,no-resolve
- IP-CIDR,120.241.147.226/32,$app_name,no-resolve
- IP-CIDR,120.253.253.226/32,$app_name,no-resolve
- IP-CIDR,120.253.255.162/32,$app_name,no-resolve
- IP-CIDR,120.253.255.34/32,$app_name,no-resolve
- IP-CIDR,120.253.255.98/32,$app_name,no-resolve
- IP-CIDR,180.163.150.162/32,$app_name,no-resolve
- IP-CIDR,180.163.150.34/32,$app_name,no-resolve
- IP-CIDR,180.163.151.162/32,$app_name,no-resolve
- IP-CIDR,180.163.151.34/32,$app_name,no-resolve
- IP-CIDR,203.208.39.0/24,$app_name,no-resolve
- IP-CIDR,203.208.40.0/24,$app_name,no-resolve
- IP-CIDR,203.208.41.0/24,$app_name,no-resolve
- IP-CIDR,203.208.43.0/24,$app_name,no-resolve
- IP-CIDR,203.208.50.0/24,$app_name,no-resolve
- IP-CIDR,220.181.174.162/32,$app_name,no-resolve
- IP-CIDR,220.181.174.226/32,$app_name,no-resolve
- IP-CIDR,220.181.174.34/32,$app_name,no-resolve
# LAN
- DOMAIN,injections.adguard.org,DIRECT
- DOMAIN,local.adguard.org,DIRECT
- DOMAIN-SUFFIX,local,DIRECT
- IP-CIDR,127.0.0.0/8,DIRECT
- IP-CIDR,172.16.0.0/12,DIRECT
- IP-CIDR,192.168.0.0/16,DIRECT
- IP-CIDR,10.0.0.0/8,DIRECT
- IP-CIDR,17.0.0.0/8,DIRECT
- IP-CIDR,100.64.0.0/10,DIRECT
- IP-CIDR,224.0.0.0/4,DIRECT
- IP-CIDR6,fe80::/10,DIRECT
# 剩余未匹配的国内网站
- DOMAIN-SUFFIX,cn,DIRECT
- DOMAIN-KEYWORD,-cn,DIRECT
# 最终规则
- GEOIP,CN,DIRECT - GEOIP,CN,DIRECT
- MATCH,$app_name - MATCH,$app_name
+63 -6
View File
@@ -14,12 +14,69 @@
secure_path: "{{ $secure_path }}", secure_path: "{{ $secure_path }}",
}; };
</script> </script>
<script type="module" crossorigin src="/assets/admin/assets/index.js"></script> @php
<link rel="stylesheet" crossorigin href="/assets/admin/assets/index.css" /> $manifestPath = public_path('assets/admin/manifest.json');
<link rel="stylesheet" crossorigin href="/assets/admin/assets/vendor.css"> $manifest = file_exists($manifestPath) ? json_decode(file_get_contents($manifestPath), true) : null;
<script src="/assets/admin/locales/en-US.js"></script> $entry = is_array($manifest) ? ($manifest['index.html'] ?? null) : null;
<script src="/assets/admin/locales/zh-CN.js"></script> $scripts = [];
<script src="/assets/admin/locales/ko-KR.js"></script> $styles = [];
$locales = [];
if (is_array($entry)) {
$visited = [];
$collectAssets = function ($chunkName) use (&$collectAssets, &$manifest, &$visited, &$scripts, &$styles) {
if (isset($visited[$chunkName]) || !isset($manifest[$chunkName]) || !is_array($manifest[$chunkName])) {
return;
}
$visited[$chunkName] = true;
$chunk = $manifest[$chunkName];
if (!empty($chunk['css']) && is_array($chunk['css'])) {
foreach ($chunk['css'] as $cssFile) {
$styles[$cssFile] = $cssFile;
}
}
if (!empty($chunk['imports']) && is_array($chunk['imports'])) {
foreach ($chunk['imports'] as $import) {
$collectAssets($import);
}
}
if (!empty($chunk['isEntry']) && !empty($chunk['file'])) {
$scripts[$chunk['file']] = $chunk['file'];
}
};
$collectAssets('index.html');
}
foreach (glob(public_path('assets/admin/locales/*.js')) ?: [] as $localeFile) {
$locales[] = 'locales/' . basename($localeFile);
}
sort($locales);
@endphp
@if($entry && count($scripts) > 0)
@foreach($styles as $css)
<link rel="stylesheet" crossorigin href="/assets/admin/{{ $css }}" />
@endforeach
@foreach($locales as $locale)
<script src="/assets/admin/{{ $locale }}"></script>
@endforeach
@foreach($scripts as $js)
<script type="module" crossorigin src="/assets/admin/{{ $js }}"></script>
@endforeach
@else
{{-- Fallback: hardcoded paths for backward compatibility --}}
<script type="module" crossorigin src="/assets/admin/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/assets/admin/assets/index.css" />
<link rel="stylesheet" crossorigin href="/assets/admin/assets/vendor.css">
<script src="/assets/admin/locales/en-US.js"></script>
<script src="/assets/admin/locales/zh-CN.js"></script>
<script src="/assets/admin/locales/ko-KR.js"></script>
@endif
</head> </head>
<body> <body>

Some files were not shown because too many files have changed in this diff Show More