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
stopasgroup=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_ENV=local
APP_ENV=production
APP_KEY=base64:PZXk5vTuTinfeEVG5FpYv2l6WEhLsyvGpiWK7IgJJ60=
APP_DEBUG=false
APP_URL=http://localhost
+1 -10
View File
@@ -58,7 +58,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
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=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=${{ steps.get_version.outputs.version }}
@@ -98,12 +98,3 @@ jobs:
allow: |
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 .git && \
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
RUN composer install --no-cache --no-dev \
&& php artisan storage:link \
&& cp -r plugins/ /opt/default-plugins/ \
&& chown -R www:www /www \
&& chmod -R 775 /www \
&& mkdir -p /data \
@@ -38,7 +40,8 @@ RUN composer install --no-cache --no-dev \
ENV ENABLE_WEB=true \
ENABLE_HORIZON=true \
ENABLE_REDIS=false
ENABLE_REDIS=false \
ENABLE_WS_SERVER=false
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);
if (!$commissionBalance) continue;
if ((int)admin_setting('withdraw_close_enable', 0)) {
$inviter->balance = $inviter->balance + $commissionBalance;
$inviter->increment('balance', $commissionBalance);
} else {
$inviter->commission_balance = $inviter->commission_balance + $commissionBalance;
$inviter->increment('commission_balance', $commissionBalance);
}
if (!$inviter->save()) {
DB::rollBack();
+5 -6
View File
@@ -43,12 +43,11 @@ class CheckOrder extends Command
*/
public function handle()
{
ini_set('memory_limit', -1);
$orders = Order::whereIn('status', [Order::STATUS_PENDING, Order::STATUS_PROCESSING])
Order::whereIn('status', [Order::STATUS_PENDING, Order::STATUS_PROCESSING])
->orderBy('created_at', 'ASC')
->get();
foreach ($orders as $order) {
OrderHandleJob::dispatch($order->trade_no);
}
->lazyById(200)
->each(function ($order) {
OrderHandleJob::dispatch($order->trade_no);
});
}
}
+7 -8
View File
@@ -38,15 +38,14 @@ class CheckTicket extends Command
*/
public function handle()
{
ini_set('memory_limit', -1);
$tickets = Ticket::where('status', 0)
Ticket::where('status', 0)
->where('updated_at', '<=', time() - 24 * 3600)
->where('reply_status', 0)
->get();
foreach ($tickets as $ticket) {
if ($ticket->user_id === $ticket->last_reply_user_id) continue;
$ticket->status = Ticket::STATUS_CLOSED;
$ticket->save();
}
->lazyById(200)
->each(function ($ticket) {
if ($ticket->user_id === $ticket->last_reply_user_id) return;
$ticket->status = Ticket::STATUS_CLOSED;
$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;
use App\Models\Log;
use App\Models\AdminAuditLog;
use App\Models\StatServer;
use App\Models\StatUser;
use Illuminate\Console\Command;
@@ -43,6 +43,6 @@ class ResetLog extends Command
{
StatUser::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)) {
abort(500, '管理员账号注册失败,请重试');
}
if (function_exists('exec')) {
self::restoreProtectedPlugins($this);
}
self::restoreProtectedPlugins($this);
$this->info('正在安装默认插件...');
PluginManager::installDefaultPlugins();
$this->info('默认插件安装完成');
@@ -369,61 +367,31 @@ class XboardInstall extends Command
/**
* 还原内置受保护插件(可在安装和更新时调用)
* Docker 部署时 plugins/ 目录被外部挂载覆盖,需要从镜像备份中还原默认插件
*/
public static function restoreProtectedPlugins(Command $console = null)
{
exec("git config core.filemode false", $output, $returnVar);
$cmd = "git status --porcelain plugins/ 2>/dev/null";
exec($cmd, $output, $returnVar);
if (!empty($output)) {
$hasNonNewFiles = false;
foreach ($output as $line) {
$status = trim(substr($line, 0, 2));
if ($status !== 'A') {
$hasNonNewFiles = true;
break;
}
$backupBase = '/opt/default-plugins';
$pluginsBase = base_path('plugins');
if (!File::isDirectory($backupBase)) {
$console?->info('非 Docker 环境或备份目录不存在,跳过插件还原。');
return;
}
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
$dirName = Str::studly($pluginCode);
$source = "{$backupBase}/{$dirName}";
$target = "{$pluginsBase}/{$dirName}";
if (!File::isDirectory($source)) {
continue;
}
if ($hasNonNewFiles) {
if ($console)
$console->info("检测到 plugins 目录有变更,正在还原...");
foreach ($output as $line) {
$status = trim(substr($line, 0, 2));
$filePath = trim(substr($line, 3));
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 目录状态正常,无需还原。");
// 先清除旧文件再复制,避免重命名后残留旧文件
File::deleteDirectory($target);
File::copyDirectory($source, $target);
$console?->info("已同步默认插件 [{$dirName}]");
}
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ class XboardUpdate extends Command
public function handle()
{
$this->info('正在导入数据库请稍等...');
Artisan::call("migrate");
Artisan::call("migrate", ['--force' => true]);
$this->info(Artisan::output());
$this->info('正在检查内置插件文件...');
XboardInstall::restoreProtectedPlugins($this);
+4 -4
View File
@@ -32,11 +32,11 @@ class Kernel extends ConsoleKernel
// v2board
$schedule->command('xboard:statistics')->dailyAt('0:10')->onOneServer();
// check
$schedule->command('check:order')->everyMinute()->onOneServer();
$schedule->command('check:commission')->everyMinute()->onOneServer();
$schedule->command('check:ticket')->everyMinute()->onOneServer();
$schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:ticket')->everyMinute()->onOneServer()->withoutOverlapping(5);
// reset
$schedule->command('reset:traffic')->everyMinute()->onOneServer();
$schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10);
$schedule->command('reset:log')->daily()->onOneServer();
// send
$schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer();
+10 -1
View File
@@ -1,6 +1,5 @@
<?php
use App\Support\Setting;
use Illuminate\Support\Facades\App;
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')) {
/**
* 批量获取配置参数,性能优化版本
@@ -3,7 +3,8 @@
namespace App\Http\Controllers\V1\Server;
use App\Http\Controllers\Controller;
use App\Jobs\UpdateAliveDataJob;
use App\Jobs\UserAliveSyncJob;
use App\Services\NodeSyncService;
use App\Services\ServerService;
use App\Services\UserService;
use App\Utils\CacheKey;
@@ -88,117 +89,13 @@ class UniProxyController extends Controller
public function config(Request $request)
{
$node = $this->getNodeInfo($request);
$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']
],
'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 = ServerService::buildNodeConfig($node);
$response['base_config'] = [
'push_interval' => (int) admin_setting('server_push_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));
if (strpos($request->header('If-None-Match', ''), $eTag) !== false) {
return response(null, 304);
@@ -226,7 +123,7 @@ class UniProxyController extends Controller
'error' => 'Invalid online data'
], 400);
}
UpdateAliveDataJob::dispatch($data, $node->type, $node->id);
UserAliveSyncJob::dispatch($data, $node->type, $node->id);
return response()->json(['data' => true]);
}
@@ -16,6 +16,7 @@ use App\Services\PaymentService;
use App\Services\PlanService;
use App\Services\UserService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OrderController extends Controller
{
@@ -70,7 +70,7 @@ class TicketController extends Controller
if ($ticket->status) {
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')]);
}
$ticketService = new TicketService();
+25 -35
View File
@@ -18,6 +18,7 @@ use App\Utils\CacheKey;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class UserController extends Controller
{
@@ -31,20 +32,14 @@ class UserController extends Controller
public function getActiveSession(Request $request)
{
$user = User::find($request->user()->id);
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$user = $request->user();
$authService = new AuthService($user);
return $this->success($authService->getSessions());
}
public function removeActiveSession(Request $request)
{
$user = User::find($request->user()->id);
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$user = $request->user();
$authService = new AuthService($user);
return $this->success($authService->removeSession($request->input('session_id')));
}
@@ -62,10 +57,7 @@ class UserController extends Controller
public function changePassword(UserChangePassword $request)
{
$user = User::find($request->user()->id);
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$user = $request->user();
if (
!Helper::multiPasswordVerify(
$user->password_algo,
@@ -163,10 +155,7 @@ class UserController extends Controller
public function resetSecurity(Request $request)
{
$user = User::find($request->user()->id);
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$user = $request->user();
$user->uuid = Helper::guid(true);
$user->token = Helper::guid();
if (!$user->save()) {
@@ -182,10 +171,7 @@ class UserController extends Controller
'remind_traffic'
]);
$user = User::find($request->user()->id);
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$user = $request->user();
try {
$user->update($updateData);
} catch (\Exception $e) {
@@ -197,27 +183,31 @@ class UserController extends Controller
public function transfer(UserTransfer $request)
{
$user = User::find($request->user()->id);
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
if ($request->input('transfer_amount') > $user->commission_balance) {
return $this->fail([400, __('Insufficient commission balance')]);
}
$user->commission_balance = $user->commission_balance - $request->input('transfer_amount');
$user->balance = $user->balance + $request->input('transfer_amount');
if (!$user->save()) {
return $this->fail([400, __('Transfer failed')]);
$amount = $request->input('transfer_amount');
try {
DB::transaction(function () use ($request, $amount) {
$user = User::lockForUpdate()->find($request->user()->id);
if (!$user) {
throw new \Exception(__('The user does not exist'));
}
if ($amount > $user->commission_balance) {
throw new \Exception(__('Insufficient commission balance'));
}
$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);
}
public function getQuickLoginUrl(Request $request)
{
$user = User::find($request->user()->id);
if (!$user) {
return $this->fail([400, __('The user does not exist')]);
}
$user = $request->user();
$url = $this->loginService->generateQuickLoginUrl($user, $request->input('redirect'));
return $this->success($url);
@@ -4,20 +4,12 @@ namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ConfigSave;
use App\Protocols\Clash;
use App\Protocols\ClashMeta;
use App\Protocols\SingBox;
use App\Protocols\Stash;
use App\Protocols\Surfboard;
use App\Protocols\Surge;
use App\Models\SubscribeTemplate;
use App\Services\MailService;
use App\Services\TelegramService;
use App\Services\ThemeService;
use App\Utils\Dict;
use Illuminate\Console\Command;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
class ConfigController extends Controller
{
@@ -57,31 +49,24 @@ class ConfigController extends Controller
'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)
{
$app_url = admin_setting('app_url');
if (blank($app_url))
return $this->fail([422, '请先设置站点网址']);
$hookUrl = $app_url . '/api/v1/guest/telegram/webhook?' . http_build_query([
$hookUrl = $this->resolveTelegramWebhookUrl();
if (blank($hookUrl)) {
return $this->fail([422, 'Telegram Webhook地址未配置']);
}
$hookUrl .= '?' . http_build_query([
'access_token' => md5(admin_setting('telegram_bot_token', $request->input('telegram_bot_token')))
]);
$telegramService = new TelegramService($request->input('telegram_bot_token'));
$telegramService->getMe();
$telegramService->setWebhook($hookUrl);
$telegramService->setWebhook(url: $hookUrl);
$telegramService->registerBotCommands();
return $this->success(true);
return $this->success([
'success' => true,
'webhook_url' => $hookUrl,
'webhook_base_url' => $this->getTelegramWebhookBaseUrl(),
]);
}
public function fetch(Request $request)
@@ -131,6 +116,7 @@ class ConfigController extends Controller
'tos_url' => admin_setting('tos_url'),
'currency' => admin_setting('currency', 'CNY'),
'currency_symbol' => admin_setting('currency_symbol', '¥'),
'ticket_must_wait_reply' => (bool) admin_setting('ticket_must_wait_reply', 0),
],
'subscribe' => [
'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_push_interval' => admin_setting('server_push_interval', 60),
'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_template' => admin_setting('email_template', 'default'),
@@ -171,6 +159,7 @@ class ConfigController extends Controller
'telegram' => [
'telegram_bot_enable' => (bool) admin_setting('telegram_bot_enable', 0),
'telegram_bot_token' => admin_setting('telegram_bot_token'),
'telegram_webhook_url' => admin_setting('telegram_webhook_url'),
'telegram_discuss_link' => admin_setting('telegram_discuss_link')
],
'app' => [
@@ -208,14 +197,14 @@ class ConfigController extends Controller
],
'subscribe_template' => [
'subscribe_template_singbox' => $this->formatTemplateContent(
admin_setting('subscribe_template_singbox', $this->getDefaultTemplate('singbox')),
subscribe_template('singbox') ?? '',
'json'
),
'subscribe_template_clash' => admin_setting('subscribe_template_clash', $this->getDefaultTemplate('clash')),
'subscribe_template_clashmeta' => admin_setting('subscribe_template_clashmeta', $this->getDefaultTemplate('clashmeta')),
'subscribe_template_stash' => admin_setting('subscribe_template_stash', $this->getDefaultTemplate('stash')),
'subscribe_template_surge' => admin_setting('subscribe_template_surge', $this->getDefaultTemplate('surge')),
'subscribe_template_surfboard' => admin_setting('subscribe_template_surfboard', $this->getDefaultTemplate('surfboard'))
'subscribe_template_clash' => subscribe_template('clash') ?? '',
'subscribe_template_clashmeta' => subscribe_template('clashmeta') ?? '',
'subscribe_template_stash' => subscribe_template('stash') ?? '',
'subscribe_template_surge' => subscribe_template('surge') ?? '',
'subscribe_template_surfboard' => subscribe_template('surfboard') ?? ''
]
];
}
@@ -224,7 +213,20 @@ class ConfigController extends Controller
{
$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) {
if (isset($templateKeys[$k])) {
SubscribeTemplate::setContent($templateKeys[$k], $v);
continue;
}
if ($k == 'frontend_theme') {
$themeService = app(ThemeService::class);
$themeService->switch($v);
@@ -267,50 +269,32 @@ class ConfigController extends Controller
};
}
/**
* 获取默认模板内容
*
* @param string $type 模板类型
* @return string 默认模板内容
*/
private function getDefaultTemplate(string $type): string
private function getTelegramWebhookBaseUrl(): ?string
{
$fileMap = [
'singbox' => [SingBox::CUSTOM_TEMPLATE_FILE, SingBox::DEFAULT_TEMPLATE_FILE],
'clash' => [Clash::CUSTOM_TEMPLATE_FILE, Clash::DEFAULT_TEMPLATE_FILE],
'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 '';
$customUrl = trim((string) admin_setting('telegram_webhook_url', ''));
if ($customUrl !== '') {
return rtrim($customUrl, '/');
}
// 按优先级查找可用的模板文件
foreach ($fileMap[$type] as $file) {
$content = $this->getTemplateContent($file);
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;
}
$appUrl = trim((string) admin_setting('app_url', ''));
if ($appUrl !== '') {
return rtrim($appUrl, '/');
}
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',
]);
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->success(true);
@@ -23,7 +23,7 @@ class RouteController extends Controller
$params = $request->validate([
'remarks' => 'required',
'match' => 'required|array',
'action' => 'required|in:block,dns',
'action' => 'required|in:block,direct,dns,proxy',
'action_value' => 'nullable'
], [
'remarks.required' => '备注不能为空',
@@ -536,19 +536,20 @@ class StatController extends Controller
}
$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) {
$previousValue = isset($previousData[$data->id]) ? $previousData[$data->id]->value : 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[] = [
'id' => (string) $data->id,
'name' => $name,
'value' => $data->value, // Convert to GB
'previousValue' => $previousValue, // Convert to GB
'name' => $names[$data->id] ?? ($type === 'node' ? "Node {$data->id}" : "User {$data->id}"),
'value' => $data->value,
'previousValue' => $previousValue,
'change' => $change,
'timestamp' => date('c', $endDate)
];
@@ -3,7 +3,7 @@
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\Log as LogModel;
use App\Models\AdminAuditLog;
use App\Utils\CacheKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -23,37 +23,10 @@ class SystemController extends Controller
'schedule' => $this->getScheduleStatus(),
'horizon' => $this->getHorizonStatus(),
'schedule_last_runtime' => Cache::get(CacheKey::get('SCHEDULE_LAST_CHECK_AT', null)),
'logs' => $this->getLogStatistics()
];
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)
{
return $this->success(collect($workload->get())->sortBy('name')->values()->toArray());
@@ -125,34 +98,26 @@ class SystemController extends Controller
})->count();
}
public function getSystemLog(Request $request)
public function getAuditLog(Request $request)
{
$current = $request->input('current') ? $request->input('current') : 1;
$pageSize = $request->input('page_size') >= 10 ? $request->input('page_size') : 10;
$level = $request->input('level');
$keyword = $request->input('keyword');
$current = max(1, (int) $request->input('current', 1));
$pageSize = max(10, (int) $request->input('page_size', 10));
$builder = LogModel::orderBy('created_at', 'DESC')
->when($level, function ($query) use ($level) {
return $query->where('level', strtoupper($level));
})
->when($keyword, function ($query) use ($keyword) {
return $query->where(function ($q) use ($keyword) {
$q->where('data', 'like', '%' . $keyword . '%')
->orWhere('context', 'like', '%' . $keyword . '%')
->orWhere('title', 'like', '%' . $keyword . '%')
->orWhere('uri', 'like', '%' . $keyword . '%');
$builder = AdminAuditLog::with('admin:id,email')
->orderBy('id', 'DESC')
->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($request->input('keyword'), function ($q, $keyword) {
$q->where(function ($q) use ($keyword) {
$q->where('uri', 'like', '%' . $keyword . '%')
->orWhere('request_data', 'like', '%' . $keyword . '%');
});
});
$total = $builder->count();
$res = $builder->forPage($current, $pageSize)
->get();
$res = $builder->forPage($current, $pageSize)->get();
return response([
'data' => $res,
'total' => $total
]);
return response(['data' => $res, 'total' => $total]);
}
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\User;
use App\Services\AuthService;
use App\Services\NodeSyncService;
use App\Services\UserService;
use App\Traits\QueryOperators;
use App\Utils\Helper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
@@ -34,27 +36,15 @@ class UserController extends Controller
return $this->success($user->save());
}
/**
* Apply filters and sorts to the query builder
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applyFiltersAndSorts(Request $request, Builder $builder): void
// Apply filters and sorts to the query builder.
private function applyFiltersAndSorts(Request $request, Builder|QueryBuilder $builder): void
{
$this->applyFilters($request, $builder);
$this->applySorting($request, $builder);
}
/**
* Apply filters to the query builder
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applyFilters(Request $request, Builder $builder): void
// Apply filters to the query builder.
private function applyFilters(Request $request, Builder|QueryBuilder $builder): void
{
if (!$request->has('filter')) {
return;
@@ -70,18 +60,14 @@ class UserController extends Controller
});
}
/**
* Build the filter query based on field and value
*
* @param Builder $query
* @param string $field
* @param mixed $value
* @return void
*/
private function buildFilterQuery(Builder $query, string $field, mixed $value): void
// Build one filter query condition.
private function buildFilterQuery(Builder|QueryBuilder $query, string $field, mixed $value): void
{
// 处理关联查询
if (str_contains($field, '.')) {
if (!method_exists($query, 'whereHas')) {
return;
}
[$relation, $relationField] = explode('.', $field);
$query->whereHas($relation, function ($q) use ($relationField, $value) {
if (is_array($value)) {
@@ -126,14 +112,8 @@ class UserController extends Controller
$this->applyQueryCondition($query, $queryField, $operator, $filterValue);
}
/**
* Apply sorting to the query builder
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applySorting(Request $request, Builder $builder): void
// Apply sorting rules to the query builder.
private function applySorting(Request $request, Builder|QueryBuilder $builder): void
{
if (!$request->has('sort')) {
return;
@@ -146,19 +126,50 @@ class UserController extends Controller
});
}
/**
* Fetch paginated user list with filters and sorting
*
* @param Request $request
* @return \Illuminate\Http\Response
*/
// Resolve bulk operation scope and normalize user_ids.
private function resolveScope(Request $request): array
{
$scope = $request->input('scope');
$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)
{
$current = $request->input('current', 1);
$pageSize = $request->input('pageSize', 10);
$userModel = User::with(['plan:id,name', 'invite_user:id,email', 'group:id,name'])
->select(DB::raw('*, (u+d) as total_used'));
$userModel = User::query()
->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);
@@ -172,12 +183,7 @@ class UserController extends Controller
return $this->paginate($users);
}
/**
* Transform user data for response
*
* @param User $user
* @return array<string, mixed>
*/
// Transform user fields for API response.
public static function transformUserData(User $user): array
{
$user = $user->toArray();
@@ -253,19 +259,25 @@ class UserController extends Controller
return $this->success(true);
}
/**
* 导出用户数据为CSV格式
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\StreamedResponse
*/
// Export users to CSV.
public function dumpCSV(Request $request)
{
ini_set('memory_limit', '-1');
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问题
$query = User::with('plan:id,name')
$query = User::query()
->with('plan:id,name')
->orderBy('id', 'asc')
->select([
'email',
@@ -279,7 +291,11 @@ class UserController extends Controller
'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';
@@ -439,26 +455,65 @@ class UserController extends Controller
public function sendMail(UserSendMail $request)
{
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';
$sort = $request->input('sort') ? $request->input('sort') : 'created_at';
$hourlyLimit = (int) env('MASS_EMAIL_HOURLY_LIMIT', 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');
$content = $request->input('content');
$templateValue = [
'name' => admin_setting('app_name', 'Notification Service'),
'url' => admin_setting('app_url'),
'content' => $content
];
$appName = admin_setting('app_name', 'Notification Service');
$appUrl = admin_setting('app_url');
$chunkSize = 1000;
$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) {
$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;
dispatch(new SendEmailJob([
'email' => $user->email,
@@ -475,10 +530,29 @@ class UserController extends Controller
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';
$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 {
$builder->update([
'banned' => 1
@@ -487,16 +561,11 @@ class UserController extends Controller
Log::error($e);
return $this->fail([500, '处理失败']);
}
// Full refresh not implemented.
return $this->success(true);
}
/**
* 删除用户及其关联数据
*
* @param Request $request
* @return JsonResponse
*/
// Delete user and related data.
public function destroy(Request $request)
{
$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,
// \App\Http\Middleware\VerifyCsrfToken::class,
// \Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\ApplyRuntimeSettings::class,
],
'api' => [
@@ -46,6 +47,7 @@ class Kernel extends HttpKernel
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
// \Illuminate\Routing\Middleware\ThrottleRequests::class . ':api',
// \Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\ApplyRuntimeSettings::class,
\App\Http\Middleware\ForceJson::class,
\App\Http\Middleware\Language::class,
'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;
use App\Models\AdminAuditLog;
use Closure;
class RequestLog
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
private const SENSITIVE_KEYS = ['password', 'token', 'secret', 'key', 'api_key'];
public function handle($request, Closure $next)
{
if ($request->method() === 'POST') {
$path = $request->path();
info("POST {$path}");
};
return $next($request);
if ($request->method() !== 'POST') {
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',
'currency' => '',
'currency_symbol' => '',
'ticket_must_wait_reply' => '',
// subscribe
'plan_change_enable' => '',
'reset_traffic_method' => 'in:0,1,2,3,4',
@@ -50,6 +51,8 @@ class ConfigSave extends FormRequest
'server_pull_interval' => 'integer',
'server_push_interval' => 'integer',
'device_limit_mode' => 'integer',
'server_ws_enable' => 'boolean',
'server_ws_url' => 'nullable|url',
// frontend
'frontend_theme' => '',
'frontend_theme_sidebar' => 'nullable|in:dark,light',
@@ -68,6 +71,7 @@ class ConfigSave extends FormRequest
// telegram
'telegram_bot_enable' => '',
'telegram_bot_token' => '',
'telegram_webhook_url' => 'nullable|url',
'telegram_discuss_id' => '',
'telegram_channel_id' => '',
'telegram_discuss_link' => 'nullable|url',
@@ -128,6 +132,7 @@ class ConfigSave extends FormRequest
'subscribe_url.url' => '订阅URL格式不正确,必须携带http(s)://',
'server_token.min' => '通讯密钥长度必须大于16位',
'tos_url.url' => '服务条款URL格式不正确,必须携带http(s)://',
'telegram_webhook_url.url' => 'Telegram Webhook地址格式不正确,必须携带http(s)://',
'telegram_discuss_link.url' => 'Telegram群组地址必须为URL格式,必须携带http(s)://',
'logo.url' => 'LOGO URL格式不正确,必须携带https(s)://',
'secure_path.min' => '后台路径长度最小为8位',
+60 -4
View File
@@ -8,6 +8,23 @@ use Illuminate\Foundation\Http\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 = [
'shadowsocks' => [
'cipher' => 'required|string',
@@ -67,8 +84,8 @@ class ServerSave extends FormRequest
'tls_settings' => 'nullable|array',
],
'mieru' => [
'transport' => 'required|string',
'multiplexing' => 'required|string',
'transport' => 'required|string|in:TCP,UDP',
'traffic_pattern' => 'string'
],
'anytls' => [
'tls' => 'nullable|array',
@@ -97,6 +114,9 @@ class ServerSave extends FormRequest
'rate' => 'required|numeric',
'rate_time_enable' => 'nullable|boolean',
'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.*.end' => 'required_with:rate_time_ranges|string|date_format:H:i',
'rate_time_ranges.*.rate' => 'required_with:rate_time_ranges|numeric|min:0',
@@ -109,13 +129,45 @@ class ServerSave extends FormRequest
$type = $this->input('type');
$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;
}
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()
{
return [
@@ -136,7 +188,11 @@ class ServerSave extends FormRequest
'networkSettings.array' => '传输协议配置有误',
'ruleSettings.array' => '规则配置有误',
'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('/getQueueWorkload', [SystemController::class, 'getQueueWorkload']);
$router->get('/getQueueMasters', '\\Laravel\\Horizon\\Http\\Controllers\\MasterSupervisorController@index');
$router->get('/getSystemLog', [SystemController::class, 'getSystemLog']);
$router->get('/getHorizonFailedJobs', [SystemController::class, 'getHorizonFailedJobs']);
$router->post('/clearSystemLog', [SystemController::class, 'clearSystemLog']);
$router->get('/getLogClearStats', [SystemController::class, 'getLogClearStats']);
$router->any('/getAuditLog', [SystemController::class, 'getAuditLog']);
});
// 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\TrojanTidalabController;
use App\Http\Controllers\V1\Server\UniProxyController;
use App\Http\Controllers\V2\Server\ServerController;
use Illuminate\Contracts\Routing\Registrar;
class ServerRoute
@@ -15,6 +16,8 @@ class ServerRoute
'prefix' => 'server',
'middleware' => 'server'
], function ($route) {
$route->post('handshake', [ServerController::class, 'handshake']);
$route->post('report', [ServerController::class, 'report']);
$route->get('config', [UniProxyController::class, 'config']);
$route->get('user', [UniProxyController::class, 'user']);
$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 Illuminate\Support\Facades\Log;
class UpdateAliveDataJob implements ShouldQueue
class UserAliveSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -25,7 +25,7 @@ class UpdateAliveDataJob implements ShouldQueue
private readonly string $nodeType,
private readonly int $nodeId
) {
$this->onQueue('online_sync');
$this->onQueue('user_alive_sync');
}
public function handle(): void
@@ -97,7 +97,7 @@ class UpdateAliveDataJob implements ShouldQueue
}
}
} catch (\Throwable $e) {
Log::error('UpdateAliveDataJob failed', [
Log::error('UserAliveSyncJob failed', [
'error' => $e->getMessage(),
]);
$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_push_at 最后推送时间(Unix时间戳)
* @property-read int $online 在线用户数
* @property-read int $online_conn 在线连接数
* @property-read array|null $metrics 节点指标指标
* @property-read int $is_online 是否在线(1在线 0离线)
* @property-read string $available_status 可用状态描述
* @property-read string $cache_key 缓存键
@@ -112,6 +114,9 @@ class Server extends Model
'route_ids' => 'array',
'tags' => 'array',
'protocol_settings' => 'array',
'custom_outbounds' => 'array',
'custom_routes' => 'array',
'cert_config' => 'array',
'last_check_at' => 'integer',
'last_push_at' => 'integer',
'show' => 'boolean',
@@ -121,19 +126,55 @@ class Server extends Model
'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 = [
self::TYPE_TROJAN => [
'allow_insecure' => ['type' => 'boolean', 'default' => false],
'server_name' => ['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 => [
'tls' => ['type' => 'integer', 'default' => 0],
'network' => ['type' => 'string', 'default' => null],
'rules' => ['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 => [
'tls' => ['type' => 'integer', 'default' => 0],
@@ -151,7 +192,9 @@ class Server extends Model
'private_key' => ['type' => 'string', 'default' => null],
'short_id' => ['type' => 'string', 'default' => null]
]
]
],
...self::MULTIPLEX_CONFIGURATION,
...self::UTLS_CONFIGURATION
],
self::TYPE_SHADOWSOCKS => [
'cipher' => ['type' => 'string', 'default' => null],
@@ -240,13 +283,15 @@ class Server extends Model
'tls_settings' => [
'type' => 'object',
'fields' => [
'allow_insecure' => ['type' => 'boolean', 'default' => false]
'allow_insecure' => ['type' => 'boolean', 'default' => false],
'server_name' => ['type' => 'string', 'default' => null]
]
]
],
self::TYPE_MIERU => [
'transport' => ['type' => 'string', 'default' => 'tcp'],
'multiplexing' => ['type' => 'string', 'default' => 'MULTIPLEXING_LOW']
'transport' => ['type' => 'string', 'default' => 'TCP'],
'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;
}
/**
* 检查用户是否可用节点流量且充足
*/
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;
use App\Jobs\NodeUserSyncJob;
use App\Models\User;
use App\Services\TrafficResetService;
@@ -15,12 +16,38 @@ class UserObserver
public function updated(User $user): void
{
if ($user->isDirty(['plan_id', 'expired_at'])) {
$user->refresh();
User::withoutEvents(function () use ($user) {
$nextResetTime = $this->trafficResetService->calculateNextResetTime($user);
$user->next_reset_at = $nextResetTime?->timestamp;
$user->save();
});
$this->recalculateNextResetAt($user);
}
if ($user->isDirty(['group_id', 'uuid', 'speed_limit', 'device_limit', 'banned', 'expired_at', 'transfer_enable', 'u', 'd', 'plan_id'])) {
$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');
// 优先从数据库配置中获取模板
$template = admin_setting('subscribe_template_clash', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
$template = subscribe_template('clash');
$config = Yaml::parse($template);
$proxy = [];
+149 -31
View File
@@ -35,6 +35,7 @@ class ClashMeta extends AbstractProtocol
'grpc' => '0.0.0',
'http' => '0.0.0',
'h2' => '0.0.0',
'httpupgrade' => '0.0.0',
],
'strict' => true,
],
@@ -65,13 +66,7 @@ class ClashMeta extends AbstractProtocol
$user = $this->user;
$appName = admin_setting('app_name', 'XBoard');
$template = admin_setting('subscribe_template_clashmeta', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? 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))
));
$template = subscribe_template('clashmeta');
$config = Yaml::parse($template);
$proxy = [];
@@ -199,7 +194,7 @@ class ClashMeta extends AbstractProtocol
->filter()
->mapWithKeys(function ($pair) {
if (!str_contains($pair, '=')) {
return [];
return [trim($pair) => true];
}
[$key, $value] = explode('=', $pair, 2);
return [trim($key) => trim($value)];
@@ -209,28 +204,42 @@ class ClashMeta extends AbstractProtocol
// 根据插件类型进行字段映射
switch ($plugin) {
case 'obfs':
$array['plugin-opts'] = [
'mode' => $parsedOpts['obfs'],
'host' => $parsedOpts['obfs-host'],
];
// 可选path参数
if (isset($parsedOpts['path'])) {
$array['plugin-opts']['path'] = $parsedOpts['path'];
}
case 'obfs-local':
$array['plugin'] = 'obfs';
$array['plugin-opts'] = array_filter([
'mode' => $parsedOpts['obfs'] ?? ($parsedOpts['mode'] ?? 'http'),
'host' => $parsedOpts['obfs-host'] ?? ($parsedOpts['host'] ?? 'www.bing.com'),
]);
break;
case 'v2ray-plugin':
$array['plugin-opts'] = [
$array['plugin-opts'] = array_filter([
'mode' => $parsedOpts['mode'] ?? 'websocket',
'tls' => isset($parsedOpts['tls']) && $parsedOpts['tls'] == 'true',
'host' => $parsedOpts['host'] ?? '',
'tls' => isset($parsedOpts['tls']) || isset($parsedOpts['server']),
'host' => $parsedOpts['host'] ?? null,
'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;
default:
// 对于其他插件,直接使用解析出的键值对
$array['plugin-opts'] = $parsedOpts;
}
}
@@ -252,19 +261,24 @@ class ClashMeta extends AbstractProtocol
];
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['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')) {
case 'tcp':
$array['network'] = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') {
if ($httpOpts = array_filter([
'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'),
'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/'])
])) {
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;
}
}
@@ -281,6 +295,22 @@ class ClashMeta extends AbstractProtocol
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$array['grpc-opts']['grpc-service-name'] = $serviceName;
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:
break;
}
@@ -311,6 +341,7 @@ class ClashMeta extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['servername'] = $serverName;
}
self::appendUtls($array, $protocol_settings);
break;
case 2:
$array['tls'] = true;
@@ -320,13 +351,28 @@ class ClashMeta extends AbstractProtocol
'public-key' => data_get($protocol_settings, 'reality_settings.public_key'),
'short-id' => data_get($protocol_settings, 'reality_settings.short_id')
];
$array['client-fingerprint'] = Helper::getRandFingerprint();
self::appendUtls($array, $protocol_settings);
break;
default:
break;
}
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':
$array['network'] = 'ws';
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'))
$array['grpc-opts']['grpc-service-name'] = $serviceName;
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:
break;
}
self::appendMultiplex($array, $protocol_settings);
return $array;
}
@@ -362,6 +426,9 @@ class ClashMeta extends AbstractProtocol
$array['sni'] = $serverName;
}
self::appendUtls($array, $protocol_settings);
self::appendMultiplex($array, $protocol_settings);
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
$array['network'] = 'tcp';
@@ -378,6 +445,22 @@ class ClashMeta extends AbstractProtocol
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$array['grpc-opts']['grpc-service-name'] = $serviceName;
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:
$array['network'] = 'tcp';
break;
@@ -401,6 +484,9 @@ class ClashMeta extends AbstractProtocol
if (isset($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')) {
case 1:
$array['type'] = 'hysteria';
@@ -491,8 +577,7 @@ class ClashMeta extends AbstractProtocol
'port' => $server['port'],
'username' => $password,
'password' => $password,
'transport' => strtoupper(data_get($protocol_settings, 'transport', 'TCP')),
'multiplexing' => data_get($protocol_settings, 'multiplexing', 'MULTIPLEXING_LOW')
'transport' => strtoupper(data_get($protocol_settings, 'transport', 'TCP'))
];
// 如果配置了端口范围
@@ -566,4 +651,37 @@ class ClashMeta extends AbstractProtocol
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_TROJAN,
Server::TYPE_HYSTERIA,
Server::TYPE_ANYTLS,
Server::TYPE_SOCKS,
Server::TYPE_TUIC,
Server::TYPE_HTTP,
];
protected $protocolRequirements = [
@@ -38,7 +41,10 @@ class General extends AbstractProtocol
Server::TYPE_SHADOWSOCKS => self::buildShadowsocks($item['password'], $item),
Server::TYPE_TROJAN => self::buildTrojan($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_TUIC => self::buildTuic($item['password'], $item),
Server::TYPE_HTTP => self::buildHttp($item['password'], $item),
default => '',
};
}
@@ -109,6 +115,21 @@ class General extends AbstractProtocol
if ($path = data_get($protocol_settings, 'network_settings.serviceName'))
$config['path'] = $path;
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:
break;
}
@@ -133,6 +154,9 @@ class General extends AbstractProtocol
switch ($server['protocol_settings']['tls']) {
case 1:
$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')) {
$config['sni'] = $serverName;
}
@@ -144,7 +168,9 @@ class General extends AbstractProtocol
$config['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
$config['servername'] = data_get($protocol_settings, 'reality_settings.server_name');
$config['spx'] = "/";
$config['fp'] = Helper::getRandFingerprint();
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$config['fp'] = $fp;
}
break;
default:
break;
@@ -161,6 +187,13 @@ class General extends AbstractProtocol
if ($path = data_get($protocol_settings, 'network_settings.serviceName'))
$config['serviceName'] = $path;
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':
if ($path = data_get($protocol_settings, 'network_settings.seed'))
$config['path'] = $path;
@@ -210,6 +243,19 @@ class General extends AbstractProtocol
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$array['serviceName'] = $serviceName;
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:
break;
}
@@ -225,40 +271,140 @@ class General extends AbstractProtocol
{
$protocol_settings = $server['protocol_settings'];
$params = [];
// Return empty if version is not 2
if ($server['protocol_settings']['version'] !== 2) {
return '';
}
$version = data_get($protocol_settings, 'version', 2);
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$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']);
$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";
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)
{
$name = rawurlencode($server['name']);
$credentials = base64_encode("{$password}:{$password}");
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_TROJAN,
Server::TYPE_HYSTERIA,
Server::TYPE_VLESS,
];
protected $protocolRequirements = [
@@ -42,6 +43,9 @@ class Loon extends AbstractProtocol
if ($item['type'] === Server::TYPE_HYSTERIA) {
$uri .= self::buildHysteria($item['password'], $item, $user);
}
if ($item['type'] === Server::TYPE_VLESS) {
$uri .= self::buildVless($item['password'], $item);
}
}
return response($uri)
->header('content-type', 'text/plain')
@@ -176,58 +180,77 @@ class Loon extends AbstractProtocol
return $uri;
}
public static function buildVless($uuid, $server)
{
$protocol_settings = $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}";
}
public static function buildVless($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
if ($host = data_get($network_settings, 'headers.Host')) {
$config[] = "host={$host}";
}
break;
}
return implode(',', $config) . "\r\n";
}
$config = [
"{$server['name']}=VLESS",
"{$server['host']}",
"{$server['port']}",
"{$password}",
"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)
{
+10 -3
View File
@@ -165,13 +165,18 @@ class Shadowrocket extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config['peer'] = $serverName;
}
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$config['fp'] = $fp;
}
break;
case 2:
$config['tls'] = 1;
$config['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
$config['pbk'] = data_get($protocol_settings, 'reality_settings.public_key');
$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;
default:
break;
@@ -356,8 +361,10 @@ class Shadowrocket extends AbstractProtocol
}
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";
return $uri;
}
+173 -18
View File
@@ -3,7 +3,6 @@ namespace App\Protocols;
use App\Utils\Helper;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use App\Support\AbstractProtocol;
use App\Models\Server;
@@ -54,14 +53,14 @@ class SingBox extends AbstractProtocol
'juicity' => [
'base_version' => '1.7.0'
],
'shadowtls' => [
'base_version' => '1.6.0'
],
'wireguard' => [
'base_version' => '1.5.0'
],
'anytls' => [
'base_version' => '1.12.0'
],
'mieru' => [
'base_version' => '1.12.0'
]
]
];
@@ -72,6 +71,7 @@ class SingBox extends AbstractProtocol
$this->config = $this->loadConfig();
$this->buildOutbounds();
$this->buildRule();
$this->adaptConfigForVersion();
$user = $this->user;
return response()
@@ -83,9 +83,7 @@ class SingBox extends AbstractProtocol
protected function loadConfig()
{
$jsonData = admin_setting('subscribe_template_singbox', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
$jsonData = subscribe_template('singbox');
return is_array($jsonData) ? $jsonData : json_decode($jsonData, true);
}
@@ -135,6 +133,10 @@ class SingBox extends AbstractProtocol
$httpConfig = $this->buildHttp($this->user['uuid'], $item);
$proxies[] = $httpConfig;
}
if ($item['type'] === Server::TYPE_MIERU) {
$mieruConfig = $this->buildMieru($this->user['uuid'], $item);
$proxies[] = $mieruConfig;
}
}
foreach ($outbounds as &$outbound) {
if (in_array($outbound['type'], ['urltest', 'selector'])) {
@@ -163,6 +165,91 @@ class SingBox extends AbstractProtocol
$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)
{
$protocol_settings = data_get($server, 'protocol_settings');
@@ -193,16 +280,23 @@ class SingBox extends AbstractProtocol
'uuid' => $uuid,
'security' => 'auto',
'alter_id' => 0,
'transport' => [],
'tls' => $protocol_settings['tls'] ? [
];
if ($protocol_settings['tls']) {
$array['tls'] = [
'enabled' => true,
'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'),
] : null
];
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['tls']['server_name'] = $serverName;
];
$this->appendUtls($array['tls'], $protocol_settings);
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']) {
'tcp' => data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none' ? [
'type' => 'http',
@@ -220,6 +314,20 @@ class SingBox extends AbstractProtocol
'type' => 'grpc',
'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
};
@@ -246,12 +354,10 @@ class SingBox extends AbstractProtocol
$tlsConfig = [
'enabled' => true,
'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']) {
case 1:
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
@@ -271,6 +377,8 @@ class SingBox extends AbstractProtocol
$array['tls'] = $tlsConfig;
}
$this->appendMultiplex($array, $protocol_settings);
$transport = match ($protocol_settings['network']) {
'tcp' => data_get($protocol_settings, 'network_settings.header.type') == 'http' ? [
'type' => 'http',
@@ -298,6 +406,9 @@ class SingBox extends AbstractProtocol
'host' => data_get($protocol_settings, 'network_settings.host', $server['host']),
'headers' => data_get($protocol_settings, 'network_settings.headers')
],
'quic' => [
'type' => 'quic'
],
default => null
};
@@ -322,9 +433,15 @@ class SingBox extends AbstractProtocol
'insecure' => (bool) data_get($protocol_settings, 'allow_insecure', false),
]
];
$this->appendUtls($array['tls'], $protocol_settings);
if ($serverName = data_get($protocol_settings, 'server_name')) {
$array['tls']['server_name'] = $serverName;
}
$this->appendMultiplex($array, $protocol_settings);
$transport = match (data_get($protocol_settings, 'network')) {
'grpc' => [
'type' => 'grpc',
@@ -339,7 +456,9 @@ class SingBox extends AbstractProtocol
]),
default => null
};
$array['transport'] = $transport;
if ($transport) {
$array['transport'] = array_filter($transport, fn($value) => !is_null($value));
}
return $array;
}
@@ -505,4 +624,40 @@ class SingBox extends AbstractProtocol
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_TROJAN,
Server::TYPE_TUIC,
// Server::TYPE_ANYTLS,
Server::TYPE_ANYTLS,
Server::TYPE_SOCKS,
Server::TYPE_HTTP,
];
protected $protocolRequirements = [
'stash' => [
'anytls' => [
'base_version' => '9.9.9'
'base_version' => '3.3.0' // AnyTLS 协议在3.3.0版本中添加
],
'vless' => [
'protocol_settings.tls' => [
@@ -79,13 +79,7 @@ class Stash extends AbstractProtocol
$user = $this->user;
$appName = admin_setting('app_name', 'XBoard');
$template = admin_setting('subscribe_template_stash', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? 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))
));
$template = subscribe_template('stash');
$config = Yaml::parse($template);
$proxy = [];
@@ -251,10 +245,13 @@ class Stash extends AbstractProtocol
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
$array['network'] = data_get($protocol_settings, 'network_settings.header.type', 'http');
$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;
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
$array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
if ($headerType === 'http') {
$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;
case 'ws':
@@ -286,7 +283,9 @@ class Stash extends AbstractProtocol
$array['uuid'] = $uuid;
$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')) {
case 1:
@@ -312,12 +311,15 @@ class Stash extends AbstractProtocol
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
if ($headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp') != 'tcp') {
$array['network'] = $headerType;
if ($httpOpts = array_filter([
'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'),
'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/'])
])) {
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
$array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
if ($headerType === '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;
}
}
@@ -333,11 +335,11 @@ class Stash extends AbstractProtocol
$array['network'] = 'grpc';
$array['grpc-opts']['grpc-service-name'] = data_get($protocol_settings, 'network_settings.serviceName');
break;
// case 'h2':
// $array['network'] = 'h2';
// $array['h2-opts']['host'] = data_get($protocol_settings, 'network_settings.host');
// $array['h2-opts']['path'] = data_get($protocol_settings, 'network_settings.path');
// break;
// case 'h2':
// $array['network'] = 'h2';
// $array['h2-opts']['host'] = data_get($protocol_settings, 'network_settings.host');
// $array['h2-opts']['path'] = data_get($protocol_settings, 'network_settings.path');
// break;
}
return $array;
@@ -355,8 +357,11 @@ class Stash extends AbstractProtocol
$array['udp'] = true;
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
$array['network'] = data_get($protocol_settings, 'network_settings.header.type');
$array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']);
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
$array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
if ($headerType === 'http') {
$array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']);
}
break;
case '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))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
$config = subscribe_template('surfboard');
// Subscription link
$subsURL = Helper::getSubscribeUrl($user['token']);
$subsDomain = request()->header('Host');
+98 -4
View File
@@ -18,6 +18,9 @@ class Surge extends AbstractProtocol
Server::TYPE_VMESS,
Server::TYPE_TROJAN,
Server::TYPE_HYSTERIA,
Server::TYPE_ANYTLS,
Server::TYPE_SOCKS,
Server::TYPE_HTTP,
];
protected $protocolRequirements = [
'surge.hysteria.protocol_settings.version' => [2 => '2398'],
@@ -40,7 +43,9 @@ class Surge extends AbstractProtocol
'aes-128-gcm',
'aes-192-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);
@@ -58,12 +63,22 @@ class Surge extends AbstractProtocol
$proxies .= self::buildHysteria($item['password'], $item);
$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))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
$config = subscribe_template('surge');
// Subscription link
$subsDomain = request()->header('Host');
@@ -193,6 +208,28 @@ class Surge extends AbstractProtocol
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
public static function buildHysteria($password, $server)
{
@@ -222,4 +259,61 @@ class Surge extends AbstractProtocol
$uri .= "\r\n";
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;
use App\Models\Server;
use App\Models\ServerRoute;
use App\Models\Plan;
use App\Models\User;
use App\Observers\PlanObserver;
use App\Observers\ServerObserver;
use App\Observers\ServerRouteObserver;
use App\Observers\UserObserver;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
@@ -24,5 +31,10 @@ class EventServiceProvider extends ServiceProvider
parent::boot();
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()
{
//
if (admin_setting('force_https')) {
resolve(\Illuminate\Routing\UrlGenerator::class)->forceScheme('https');
}
// HTTPS scheme is forced per-request via middleware (Octane-safe).
parent::boot();
}
+1 -1
View File
@@ -5,7 +5,6 @@ namespace App\Providers;
use App\Support\Setting;
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Log;
class SettingServiceProvider extends ServiceProvider
{
@@ -29,5 +28,6 @@ class SettingServiceProvider extends ServiceProvider
*/
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;
use App\Exceptions\ApiException;
use App\Models\InviteCode;
use App\Models\Plan;
use App\Models\User;
@@ -113,7 +114,7 @@ class RegisterService
if (!$inviteCodeModel) {
if ((int) admin_setting('invite_force', 0)) {
throw new \Exception(__('Invalid invitation code'));
throw new ApiException(__('Invalid invitation code'));
}
return null;
}
+1 -1
View File
@@ -173,7 +173,7 @@ class GiftCardService
$userService->assignPlan(
$this->user,
$plan,
$rewards['plan_validity_days'] ?? null
$rewards['plan_validity_days'] ?? 0
);
}
} else {
+56 -4
View File
@@ -13,6 +13,33 @@ use Illuminate\Support\Facades\Mail;
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'])) {
$params['template_value']['content'] = self::sanitizeMailText((string) $params['template_value']['content']);
$params['template_value']['content'] = (string) $params['template_value']['content'];
}
$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 === '') {
$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;
$logTemplateName = $params['template_name'];
try {
if ($originTemplateName === 'notify') {
$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
{
$order = $this->order;
$this->user = User::find($order->user_id);
$plan = Plan::find($order->plan_id);
HookManager::call('order.open.before', $order);
DB::transaction(function () use ($order, $plan) {
$this->user = User::lockForUpdate()->find($order->user_id);
if ($order->refund_amount) {
$this->user->balance += $order->refund_amount;
}
+172 -1
View File
@@ -27,7 +27,9 @@ class ServerService
'is_online',
'available_status',
'cache_key',
'load_status'
'load_status',
'metrics',
'online_conn'
]);
}
@@ -54,6 +56,7 @@ class ServerService
$server->port = (int) $server->port;
}
$server->password = $server->generateServerPassword($user);
$server->rate = $server->getCurrentRate();
return $server;
})->toArray();
@@ -92,6 +95,174 @@ class ServerService
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
+2 -1
View File
@@ -173,7 +173,7 @@ class UserService
// 默认设置
$user->remind_expire = admin_setting('default_remind_expire', 1);
$user->remind_traffic = admin_setting('default_remind_traffic', 1);
$user->expired_at = 0;
$user->expired_at = null;
// 可选字段
$this->setOptionalFields($user, $data);
@@ -242,6 +242,7 @@ class UserService
$user->group_id = $plan->group_id;
$user->transfer_enable = $plan->transfer_enable * 1073741824;
$user->speed_limit = $plan->speed_limit;
$user->device_limit = $plan->device_limit;
if ($validityDays > 0) {
$user = $this->extendSubscription($user, $validityDays);
+3 -1
View File
@@ -26,6 +26,8 @@ class CacheKey
'SERVER_*_LAST_PUSH_AT', // 节点最后推送时间
'SERVER_*_LOAD_STATUS', // 节点负载状态
'SERVER_*_LAST_LOAD_AT', // 节点最后负载提交时间
'SERVER_*_METRICS', // 节点指标数据
'USER_ONLINE_CONN_*_*', // 用户在线连接数 (特定节点类型_ID)
];
/**
@@ -57,7 +59,7 @@ class CacheKey
private static function matchesPattern(string $key): bool
{
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)) {
return true;
}
+20 -3
View File
@@ -142,8 +142,13 @@ class Helper
}
public static function randomPort($range): int {
$portRange = explode('-', $range);
return random_int((int)$portRange[0], (int)$portRange[1]);
$portRange = explode('-', (string) $range, 2);
$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)
@@ -183,8 +188,20 @@ class Helper
public static function getIpByDomainName($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'];
return Arr::random($fingerprints);
}
+15
View File
@@ -33,9 +33,24 @@ services:
command: php artisan horizon
depends_on:
- 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:
image: redis:7-alpine
command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777 --save 900 1 --save 300 10 --save 60 10000
restart: unless-stopped
volumes:
- ./.docker/.data/redis:/data
sysctls:
net.core.somaxconn: 1024
+3
View File
@@ -31,6 +31,9 @@
"symfony/http-client": "^7.0",
"symfony/mailgun-mailer": "^7.0",
"symfony/yaml": "*",
"webmozart/assert": "*",
"workerman/redis": "^2.0",
"workerman/workerman": "^5.1",
"zoujingli/ip2region": "^2.0"
},
"require-dev": {
+3
View File
@@ -41,6 +41,9 @@ return [
'database' => env('DB_DATABASE') ? base_path(env('DB_DATABASE')) : database_path('database.sqlite'),
'prefix' => '',
'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' => [
+40 -4
View File
@@ -60,7 +60,7 @@ return [
'prefix' => env(
'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' => [
'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' => [
'Xboard' => [
'connection' => 'redis',
'queue' => [
'default',
'order_handle',
'traffic_fetch',
'stat',
'send_email',
'send_email_mass',
'send_telegram',
'online_sync'
'user_alive_sync',
'node_sync'
],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 20,
'maxProcesses' => 5,
'tries' => 1,
'timeout' => 60,
'balanceCooldown' => 3,
],
],
+6 -54
View File
@@ -5,40 +5,9 @@ use Monolog\Handler\SyslogUdpHandler;
return [
/*
|--------------------------------------------------------------------------
| 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"
|
*/
'default' => env('LOG_CHANNEL', 'daily'),
'channels' => [
'mysql' => [
'driver' => 'custom',
'via' => App\Logging\MysqlLogger::class,
],
'stack' => [
'driver' => 'stack',
'channels' => ['daily'],
@@ -54,36 +23,19 @@ return [
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'level' => env('LOG_LEVEL', 'debug'),
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'level' => env('LOG_LEVEL', 'debug'),
'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' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
@@ -93,12 +45,12 @@ return [
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
'level' => env('LOG_LEVEL', 'debug'),
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
'level' => env('LOG_LEVEL', 'debug'),
],
'deprecations' => [
+8 -8
View File
@@ -79,7 +79,7 @@ return [
],
RequestTerminated::class => [
// FlushUploadedFiles::class,
FlushUploadedFiles::class,
],
TaskReceived::class => [
@@ -102,8 +102,8 @@ return [
OperationTerminated::class => [
FlushTemporaryContainerInstances::class,
// DisconnectFromDatabases::class,
// CollectGarbage::class,
DisconnectFromDatabases::class,
CollectGarbage::class,
],
WorkerErrorOccurred::class => [
@@ -132,7 +132,7 @@ return [
],
'flush' => [
//
\App\Services\Plugin\HookManager::class,
],
/*
@@ -147,8 +147,8 @@ return [
*/
'cache' => [
'rows' => 1000,
'bytes' => 10000,
'rows' => 5000,
'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->string('group')->comment('设置分组')->nullable();
$table->string('type')->comment('设置类型')->nullable();
$table->string('name')->comment('设置名称')->uniqid();
$table->string('name')->comment('设置名称')->unique();
$table->string('value')->comment('设置值')->nullable();
$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
curl -sSL https://resource.fit2cloud.com/1panel/package/quick_start.sh -o quick_start.sh && \
sudo bash quick_start.sh
```
## 2. 环境配置
## 2. Environment Configuration
1. 从应用商店安装:
- OpenResty(任意版本)
- 勾选“外部端口访问”以放行防火墙
- MySQL 5.7ARM 架构请使用 MariaDB
1. Install from App Store:
- OpenResty (any version)
- ⚠️ Check "External Port Access" to open firewall
- MySQL 5.7 (Use MariaDB for ARM architecture)
2. 创建数据库:
- 数据库名:`xboard`
- 用户名:`xboard`
- 权限:所有主机(%
- 请保存数据库密码,安装时需要使用
2. Create Database:
- Database name: `xboard`
- Username: `xboard`
- Access rights: All hosts (%)
- Save the database password for installation
## 3. 部署步骤
## 3. Deployment Steps
1. 添加网站:
- 进入“网站” > “创建网站” > “反向代理”
- 域名:填写你的域名
- 代号:`xboard`
- 代理地址:`127.0.0.1:7001`
1. Add Website:
- Go to "Website" > "Create Website" > "Reverse Proxy"
- Domain: Enter your domain
- Code: `xboard`
- Proxy address: `127.0.0.1:7001`
2. 配置反向代理:
2. Configure Reverse Proxy:
```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 ^~ / {
proxy_pass http://127.0.0.1:7001;
proxy_http_version 1.1;
@@ -49,31 +59,32 @@ location ^~ / {
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
# 进入网站目录
# Enter site directory
cd /opt/1panel/apps/openresty/openresty/www/sites/xboard/index
# 安装 Git(未安装时执行)
# Install Git (if not installed)
## Ubuntu/Debian
apt update && apt install -y git
## CentOS/RHEL
yum update && yum install -y git
# 克隆仓库
git clone -b compose --depth 1 https://github.com/Micah123321/Xboard ./
# Clone repository
git clone -b compose --depth 1 https://github.com/cedar2025/Xboard ./
# 配置 Docker Compose
# Configure Docker Compose
```
4. 编辑 compose.yaml
4. Edit compose.yaml:
```yaml
services:
web:
image: ghcr.io/Micah123321/xboard:new
image: ghcr.io/cedar2025/xboard:new
volumes:
- ./.docker/.data/redis/:/data/
- redis-data:/data
- ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs
@@ -91,9 +102,9 @@ services:
- 1panel-network
horizon:
image: ghcr.io/Micah123321/xboard:new
image: ghcr.io/cedar2025/xboard:new
volumes:
- ./.docker/.data/redis/:/data/
- redis-data:/data
- ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs
@@ -104,6 +115,22 @@ services:
- 1panel-network
depends_on:
- redis
ws-server:
image: ghcr.io/cedar2025/xboard:new
volumes:
- redis-data:/data
- ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs
- ./plugins:/www/plugins
restart: on-failure
ports:
- 8076:8076
networks:
- 1panel-network
command: php artisan ws-server start
depends_on:
- redis
redis:
image: redis:7-alpine
@@ -112,67 +139,72 @@ services:
networks:
- 1panel-network
volumes:
- ./.docker/.data/redis:/data
- redis-data:/data
volumes:
redis-data:
networks:
1panel-network:
external: true
```
5. 初始化安装:
5. Initialize Installation:
```bash
# 安装依赖并初始化
# Install dependencies and initialize
docker compose run -it --rm web php artisan xboard:install
```
重要配置说明:
1. 数据库配置
- Database Host:按部署方式填写:
1. 如果数据库与 Xboard 在同一网络,填写 `mysql`
2. 如果连接失败,进入:数据库 -> 选择数据库 -> 连接信息 -> 容器连接,使用其中的 Host 值
3. 如果使用外部数据库,填写实际数据库地址
- Database Port`3306`(默认端口,除非你另有配置)
- Database Name`xboard`(前面创建的数据库)
- Database User`xboard`(前面创建的用户)
- Database Password:填写前面保存的密码
⚠️ Important Configuration Notes:
1. Database Configuration
- Database Host: Choose based on your deployment:
1. If database and Xboard are in the same network, use `mysql`
2. If connection fails, go to: Database -> Select Database -> Connection Info -> Container Connection, and use the "Host" value
3. If using external database, enter your actual database host
- Database Port: `3306` (default port unless configured otherwise)
- Database Name: `xboard` (the database created earlier)
- Database User: `xboard` (the user created earlier)
- Database Password: Enter the password saved earlier
2. Redis 配置
- 选择使用内置 Redis
- 无需额外配置
2. Redis Configuration
- 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
docker compose up -d
```
6. 启动服务:
6. Start Services:
```bash
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
docker compose pull && \
docker compose run -it --rm web php artisan xboard:update && \
docker compose up -d
```
> - 如果是较早安装(旧版本),请把 `web` 替换为 `xboard`
> - If you installed earlier (old version), replace `web` with `xboard`:
```bash
docker compose pull && \
docker compose run -it --rm xboard php artisan xboard:update && \
docker compose up -d
```
> 不确定该用哪个命令?先尝试新版本命令,失败后再使用旧版本命令。
> 🤔 Not sure which to use? Try the new version command first, if it fails, use the old version command.
## 重要提示
## Important Notes
- 请确保已开启防火墙,避免 7001 端口直接暴露到公网
- 代码修改后需要重启服务才能生效
- 建议配置 SSL 证书以保障访问安全
- ⚠️ Ensure firewall is enabled to prevent port 7001 exposure to public
- Service restart is required after code modifications
- 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
## 目录
1. [环境要求](#环境要求)
2. [快速部署](#快速部署)
3. [详细配置](#详细配置)
4. [维护指南](#维护指南)
5. [故障排查](#故障排查)
## Table of Contents
1. [Requirements](#requirements)
2. [Quick Deployment](#quick-deployment)
3. [Detailed Configuration](#detailed-configuration)
4. [Maintenance Guide](#maintenance-guide)
5. [Troubleshooting](#troubleshooting)
## 环境要求
## Requirements
### 硬件要求
- CPU1 核及以上
- 内存:2GB 及以上
- 存储:可用空间 10GB+
### Hardware Requirements
- CPU: 1 core or above
- Memory: 2GB or above
- Storage: 10GB+ available space
### 软件要求
- 操作系统:Ubuntu 20.04+ / CentOS 7+ / Debian 10+
- aaPanel 最新版本
- Docker Docker Compose
- Nginx(任意版本)
### Software Requirements
- Operating System: Ubuntu 20.04+ / CentOS 7+ / Debian 10+
- Latest version of aaPanel
- Docker and Docker Compose
- Nginx (any version)
- MySQL 5.7+
## 快速部署
## Quick Deployment
### 1. 安装 aaPanel
### 1. Install aaPanel
```bash
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
```
### 2. 基础环境配置
### 2. Basic Environment Setup
#### 2.1 安装 Docker
#### 2.1 Install Docker
```bash
# 安装 Docker
# Install Docker
curl -sSL https://get.docker.com | bash
# CentOS 系统还需要执行:
# For CentOS systems, also run:
systemctl enable docker
systemctl start docker
```
#### 2.2 安装必需组件
aaPanel 面板中安装:
- Nginx(任意版本)
#### 2.2 Install Required Components
In the aaPanel dashboard, install:
- Nginx (any version)
- MySQL 5.7
- PHP Redis 不需要安装
- ⚠️ PHP and Redis are not required
### 3. 网站配置
### 3. Site Configuration
#### 3.1 创建网站
1. 进入:aaPanel > 网站 > 添加站点
2. 填写信息:
- 域名:填写你的网站域名
- 数据库:选择 MySQL
- PHP 版本:选择纯静态
#### 3.1 Create Website
1. Navigate to: aaPanel > Website > Add site
2. Fill in the information:
- Domain: Enter your site domain
- Database: Select MySQL
- PHP Version: Select Pure Static
#### 3.2 部署 Xboard
#### 3.2 Deploy Xboard
```bash
# 进入网站目录
# Enter site directory
cd /www/wwwroot/your-domain
# 清理目录
# Clean directory
chattr -i .user.ini
rm -rf .htaccess 404.html 502.html index.html .user.ini
# 克隆仓库
git clone https://github.com/Micah123321/Xboard.git ./
# Clone repository
git clone https://github.com/cedar2025/Xboard.git ./
# 准备配置文件
# Prepare configuration file
cp compose.sample.yaml compose.yaml
# 安装依赖并初始化
# Install dependencies and initialize
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
docker compose up -d
```
#### 3.4 配置反向代理
将以下内容添加到网站配置:
#### 3.4 Configure Reverse Proxy
Add the following content to your site 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;
}
location ^~ / {
proxy_pass http://127.0.0.1:7001;
proxy_http_version 1.1;
@@ -100,19 +110,20 @@ location ^~ / {
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
docker compose pull && \
docker compose run -it --rm web sh update.sh && \
docker compose up -d
```
> - 如果是较早安装(旧版本),请把 `web` 替换为 `xboard`
> - For older installations, replace `web` with `xboard`:
```bash
git config --global --add safe.directory $(pwd)
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 up -d
```
> 不确定该用哪个命令?先尝试新版本命令,失败后再使用旧版本命令。
> 🤔 Not sure which to use? Try the new version command first, if it fails, use the old version command.
### 日常维护
- 定期查看日志:`docker compose logs`
- 监控系统资源使用情况
- 定期备份数据库和配置文件
### Routine Maintenance
- Regular log checking: `docker compose logs`
- Monitor system resource usage
- Regular backup of database and configuration files
## 故障排查
## Troubleshooting
如果在安装或运行中遇到问题,请检查:
1. 系统要求是否满足
2. 所有必需端口是否可用
3. Docker 服务是否正常运行
4. Nginx 配置是否正确
5. 查看日志以获取详细报错信息
If you encounter any issues during installation or operation, please check:
1. **Empty Admin Dashboard**: If the admin panel is blank, run `git submodule update --init --recursive --force` to restore the theme files.
2. System requirements are met
3. All required ports are available
3. Docker services are running properly
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
## 目录
1. [环境要求](#环境要求)
2. [快速部署](#快速部署)
3. [详细配置](#详细配置)
4. [维护指南](#维护指南)
5. [故障排查](#故障排查)
## Table of Contents
1. [Requirements](#requirements)
2. [Quick Deployment](#quick-deployment)
3. [Detailed Configuration](#detailed-configuration)
4. [Maintenance Guide](#maintenance-guide)
5. [Troubleshooting](#troubleshooting)
## 环境要求
## Requirements
### 硬件要求
- CPU1 核及以上
- 内存:2GB 及以上
- 存储:可用空间 10GB+
### Hardware Requirements
- CPU: 1 core or above
- Memory: 2GB or above
- Storage: 10GB+ available space
### 软件要求
- 操作系统:Ubuntu 20.04+ / Debian 10+CentOS 7 不推荐)
- aaPanel 最新版本
### Software Requirements
- Operating System: Ubuntu 20.04+ / Debian 10+ (⚠️ CentOS 7 is not recommended)
- Latest version of aaPanel
- PHP 8.2
- MySQL 5.7+
- Redis
- Nginx(任意版本)
- Nginx (any version)
## 快速部署
## Quick Deployment
### 1. 安装 aaPanel
### 1. Install aaPanel
```bash
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 && \
bash install_6.0_en.sh aapanel
```
### 2. 基础环境配置
### 2. Basic Environment Setup
#### 2.1 安装 LNMP 环境
aaPanel 面板中安装:
- Nginx(任意版本)
#### 2.1 Install LNMP Environment
In the aaPanel dashboard, install:
- Nginx (any version)
- MySQL 5.7
- PHP 8.2
#### 2.2 安装 PHP 扩展
必需的 PHP 扩展:
#### 2.2 Install PHP Extensions
Required PHP extensions:
- redis
- fileinfo
- swoole
@@ -48,41 +48,41 @@ bash install_6.0_en.sh aapanel
- event
- mbstring
#### 2.3 启用所需 PHP 函数
需要启用的函数:
#### 2.3 Enable Required PHP Functions
Functions that need to be enabled:
- putenv
- proc_open
- pcntl_alarm
- pcntl_signal
### 3. 网站配置
### 3. Site Configuration
#### 3.1 创建网站
1. 进入:aaPanel > 网站 > 添加站点
2. 填写信息:
- 域名:填写你的网站域名
- 数据库:选择 MySQL
- PHP 版本:选择 8.2
#### 3.1 Create Website
1. Navigate to: aaPanel > Website > Add site
2. Fill in the information:
- Domain: Enter your site domain
- Database: Select MySQL
- PHP Version: Select 8.2
#### 3.2 部署 Xboard
#### 3.2 Deploy Xboard
```bash
# 进入网站目录
# Enter site directory
cd /www/wwwroot/your-domain
# 清理目录
# Clean directory
chattr -i .user.ini
rm -rf .htaccess 404.html 502.html index.html .user.ini
# 克隆仓库
git clone https://github.com/Micah123321/Xboard.git ./
# Clone repository
git clone https://github.com/cedar2025/Xboard.git ./
# 安装依赖
# Install dependencies
sh init.sh
```
#### 3.3 配置网站
1. 运行目录设置为 `/public`
2. 添加伪静态规则:
#### 3.3 Configure Site
1. Set running directory to `/public`
2. Add rewrite rules:
```nginx
location /downloads {
}
@@ -99,33 +99,33 @@ location ~ .*\.(js|css)?$
}
```
## 详细配置
## Detailed Configuration
### 1. 配置守护进程
1. 安装 Supervisor
2. 添加队列守护进程:
- 名称:`Xboard`
- 运行用户:`www`
- 运行目录:网站目录
- 启动命令:`php artisan horizon`
- 进程数量:1
### 1. Configure Daemon Process
1. Install Supervisor
2. Add queue daemon process:
- Name: `Xboard`
- Run User: `www`
- Running Directory: Site directory
- Start Command: `php artisan horizon`
- Process Count: 1
### 2. 配置计划任务
- 类型:Shell 脚本
- 任务名称:v2board
- 执行用户:www
- 执行周期:每 1 分钟
- 脚本内容:`php /www/wwwroot/site-directory/artisan schedule:run`
### 2. Configure Scheduled Tasks
- Type: Shell Script
- Task Name: v2board
- Run User: www
- Frequency: 1 minute
- Script Content: `php /www/wwwroot/site-directory/artisan schedule:run`
### 3. Octane 配置(可选)
#### 3.1 添加 Octane 守护进程
- 名称:Octane
- 运行用户:www
- 运行目录:网站目录
- 启动命令:`/www/server/php/82/bin/php artisan octane:start --port 7010`
- 进程数量:1
### 3. Octane Configuration (Optional)
#### 3.1 Add Octane Daemon Process
- Name: Octane
- Run User: www
- Running Directory: Site directory
- Start Command: `/www/server/php/82/bin/php artisan octane:start --port 7010`
- Process Count: 1
#### 3.2 Octane 专用伪静态规则
#### 3.2 Octane-specific Rewrite Rules
```nginx
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
# 进入网站目录
# Enter site directory
cd /www/wwwroot/your-domain
# 执行更新脚本
# Execute update script
git fetch --all && git reset --hard origin/master && git pull origin master
sh update.sh
# 如果启用了 Octane,请重启守护进程
# aaPanel > 应用商店 > 工具 > Supervisor > 重启 Octane
# If Octane is enabled, restart the daemon process
# aaPanel > App Store > Tools > Supervisor > Restart Octane
```
### 日常维护
- 定期检查日志
- 监控系统资源使用情况
- 定期备份数据库和配置文件
### Routine Maintenance
- Regular log checking
- Monitor system resource usage
- Regular backup of database and configuration files
## 故障排查
## Troubleshooting
### 常见问题
1. 修改后台路径后,需要重启服务才会生效
2. 启用 Octane 后,任何代码变更都需要重启才会生效
3. PHP 扩展安装失败时,请检查 PHP 版本是否正确
4. 数据库连接失败时,请检查数据库配置和权限
### Common Issues
1. **Empty Admin Dashboard**: If the admin panel is blank, run `git submodule update --init --recursive --force` to restore the theme files.
2. Changes to admin path require service restart to take effect
3. Any code changes after enabling Octane require restart to take effect
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
wget https://github.com/composer/composer/releases/latest/download/composer.phar -O composer.phar
php composer.phar install -vvv
git submodule update --init --recursive --force
php artisan xboard:install
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
allow-lan: true
bind-address: "*"
mode: rule
log-level: info
external-controller: 127.0.0.1:9090
unified-delay: true
tcp-concurrent: true
dns:
enable: true
# listen: 0.0.0.0:53
ipv6: false
default-nameserver:
- 223.5.5.5
- 119.29.29.29
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
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:
- https://doh.pub/dns-query
- https://dns.alidns.com/dns-query
- tls://dot.pub:853
- tls://dns.alidns.com:853
fallback:
- https://doh-pure.onedns.net/dns-query
- https://ada.openbld.net/dns-query
- https://223.5.5.5/dns-query
- https://223.6.6.6/dns-query
- https://dns.cloudflare.com/dns-query
- https://dns.google/dns-query
- tls://1.1.1.1:853
- tls://8.8.8.8:853
fallback-filter:
geoip: true
geoip-code: CN
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
- 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:
proxy-groups:
- { name: "$app_name", type: select, proxies: ["自动选择", "故障转移"] }
- { name: "自动选择", type: url-test, proxies: [], url: "http://www.gstatic.com/generate_204", interval: 86400 }
- { name: "故障转移", type: fallback, proxies: [], url: "http://www.gstatic.com/generate_204", interval: 7200 }
- { name: "$app_name", type: select, proxies: ["自动选择", "故障转移", "DIRECT"] }
- { 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: 300 }
rules:
# 自定义规则
## 您可以在此处插入您补充的自定义规则(请注意保持缩进)
# 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
# 常见广告域名屏蔽
# Custom
# Ad blocking
- DOMAIN-KEYWORD,admarvel,REJECT
- DOMAIN-KEYWORD,admaster,REJECT
- DOMAIN-KEYWORD,adsage,REJECT
@@ -235,348 +96,191 @@ rules:
- DOMAIN-KEYWORD,adsrvmedia,REJECT
- DOMAIN-KEYWORD,adwords,REJECT
- DOMAIN-KEYWORD,adservice,REJECT
- DOMAIN-SUFFIX,appsflyer.com,REJECT
- DOMAIN-KEYWORD,domob,REJECT
- DOMAIN-SUFFIX,doubleclick.net,REJECT
- DOMAIN-KEYWORD,duomeng,REJECT
- DOMAIN-KEYWORD,dwtrack,REJECT
- DOMAIN-KEYWORD,guanggao,REJECT
- DOMAIN-KEYWORD,lianmeng,REJECT
- DOMAIN-SUFFIX,mmstat.com,REJECT
- DOMAIN-KEYWORD,mopub,REJECT
- DOMAIN-KEYWORD,omgmta,REJECT
- DOMAIN-KEYWORD,openx,REJECT
- DOMAIN-KEYWORD,partnerad,REJECT
- DOMAIN-KEYWORD,pingfore,REJECT
- DOMAIN-KEYWORD,supersonicads,REJECT
- DOMAIN-KEYWORD,uedas,REJECT
- DOMAIN-KEYWORD,umeng,REJECT
- DOMAIN-KEYWORD,usage,REJECT
- DOMAIN-SUFFIX,vungle.com,REJECT
- DOMAIN-KEYWORD,wlmonitor,REJECT
- DOMAIN-KEYWORD,zjtoolbar,REJECT
# 国外网站
- DOMAIN-SUFFIX,9to5mac.com,$app_name
- DOMAIN-SUFFIX,abpchina.org,$app_name
- DOMAIN-SUFFIX,adblockplus.org,$app_name
- DOMAIN-SUFFIX,adobe.com,$app_name
- DOMAIN-SUFFIX,akamaized.net,$app_name
- DOMAIN-SUFFIX,alfredapp.com,$app_name
- DOMAIN-SUFFIX,amplitude.com,$app_name
- DOMAIN-SUFFIX,ampproject.org,$app_name
- DOMAIN-SUFFIX,android.com,$app_name
- DOMAIN-SUFFIX,angularjs.org,$app_name
- DOMAIN-SUFFIX,aolcdn.com,$app_name
- DOMAIN-SUFFIX,apkpure.com,$app_name
- DOMAIN-SUFFIX,appledaily.com,$app_name
- DOMAIN-SUFFIX,appshopper.com,$app_name
- DOMAIN-SUFFIX,appspot.com,$app_name
- DOMAIN-SUFFIX,arcgis.com,$app_name
- DOMAIN-SUFFIX,archive.org,$app_name
- DOMAIN-SUFFIX,armorgames.com,$app_name
- DOMAIN-SUFFIX,aspnetcdn.com,$app_name
- DOMAIN-SUFFIX,att.com,$app_name
- DOMAIN-SUFFIX,awsstatic.com,$app_name
- DOMAIN-SUFFIX,azureedge.net,$app_name
- DOMAIN-SUFFIX,azurewebsites.net,$app_name
- DOMAIN-SUFFIX,bing.com,$app_name
- DOMAIN-SUFFIX,bintray.com,$app_name
- DOMAIN-SUFFIX,bit.com,$app_name
- DOMAIN-SUFFIX,bit.ly,$app_name
- DOMAIN-SUFFIX,bitbucket.org,$app_name
- DOMAIN-SUFFIX,bjango.com,$app_name
- DOMAIN-SUFFIX,bkrtx.com,$app_name
- DOMAIN-SUFFIX,blog.com,$app_name
- DOMAIN-SUFFIX,blogcdn.com,$app_name
- DOMAIN-SUFFIX,blogger.com,$app_name
- DOMAIN-SUFFIX,blogsmithmedia.com,$app_name
- DOMAIN-SUFFIX,blogspot.com,$app_name
- DOMAIN-SUFFIX,blogspot.hk,$app_name
- DOMAIN-SUFFIX,bloomberg.com,$app_name
- DOMAIN-SUFFIX,box.com,$app_name
- DOMAIN-SUFFIX,box.net,$app_name
- DOMAIN-SUFFIX,cachefly.net,$app_name
- DOMAIN-SUFFIX,chromium.org,$app_name
- DOMAIN-SUFFIX,cl.ly,$app_name
- DOMAIN-SUFFIX,cloudflare.com,$app_name
- DOMAIN-SUFFIX,cloudfront.net,$app_name
- DOMAIN-SUFFIX,cloudmagic.com,$app_name
- DOMAIN-SUFFIX,cmail19.com,$app_name
- DOMAIN-SUFFIX,cnet.com,$app_name
- DOMAIN-SUFFIX,cocoapods.org,$app_name
- DOMAIN-SUFFIX,comodoca.com,$app_name
- DOMAIN-SUFFIX,crashlytics.com,$app_name
- DOMAIN-SUFFIX,culturedcode.com,$app_name
- DOMAIN-SUFFIX,d.pr,$app_name
- DOMAIN-SUFFIX,danilo.to,$app_name
- DOMAIN-SUFFIX,dayone.me,$app_name
- DOMAIN-SUFFIX,db.tt,$app_name
- DOMAIN-SUFFIX,deskconnect.com,$app_name
- DOMAIN-SUFFIX,disq.us,$app_name
- DOMAIN-SUFFIX,disqus.com,$app_name
- DOMAIN-SUFFIX,disquscdn.com,$app_name
- DOMAIN-SUFFIX,dnsimple.com,$app_name
- DOMAIN-SUFFIX,docker.com,$app_name
- DOMAIN-SUFFIX,dribbble.com,$app_name
- DOMAIN-SUFFIX,droplr.com,$app_name
- DOMAIN-SUFFIX,duckduckgo.com,$app_name
- DOMAIN-SUFFIX,dueapp.com,$app_name
- DOMAIN-SUFFIX,dytt8.net,$app_name
- DOMAIN-SUFFIX,edgecastcdn.net,$app_name
- DOMAIN-SUFFIX,edgekey.net,$app_name
- DOMAIN-SUFFIX,edgesuite.net,$app_name
- DOMAIN-SUFFIX,engadget.com,$app_name
- DOMAIN-SUFFIX,entrust.net,$app_name
- DOMAIN-SUFFIX,eurekavpt.com,$app_name
- DOMAIN-SUFFIX,evernote.com,$app_name
- DOMAIN-SUFFIX,fabric.io,$app_name
- DOMAIN-SUFFIX,fast.com,$app_name
- DOMAIN-SUFFIX,fastly.net,$app_name
- DOMAIN-SUFFIX,fc2.com,$app_name
- DOMAIN-SUFFIX,feedburner.com,$app_name
- DOMAIN-SUFFIX,feedly.com,$app_name
- DOMAIN-SUFFIX,feedsportal.com,$app_name
- DOMAIN-SUFFIX,fiftythree.com,$app_name
- DOMAIN-SUFFIX,firebaseio.com,$app_name
- DOMAIN-SUFFIX,flexibits.com,$app_name
- DOMAIN-SUFFIX,flickr.com,$app_name
- DOMAIN-SUFFIX,flipboard.com,$app_name
- DOMAIN-SUFFIX,g.co,$app_name
- DOMAIN-SUFFIX,gabia.net,$app_name
- DOMAIN-SUFFIX,geni.us,$app_name
- DOMAIN-SUFFIX,gfx.ms,$app_name
- DOMAIN-SUFFIX,ggpht.com,$app_name
- DOMAIN-SUFFIX,ghostnoteapp.com,$app_name
- DOMAIN-SUFFIX,git.io,$app_name
- DOMAIN-SUFFIX,appsflyer.com,REJECT
- DOMAIN-SUFFIX,doubleclick.net,REJECT
- DOMAIN-SUFFIX,mmstat.com,REJECT
# LAN
- DOMAIN-SUFFIX,local,DIRECT
- DOMAIN-SUFFIX,localhost,DIRECT
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,17.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,198.18.0.0/16,DIRECT,no-resolve
- IP-CIDR,224.0.0.0/4,DIRECT,no-resolve
- IP-CIDR6,::1/128,DIRECT,no-resolve
- IP-CIDR6,fc00::/7,DIRECT,no-resolve
- IP-CIDR6,fe80::/10,DIRECT,no-resolve
# Apple (App Store via proxy for foreign regions)
- DOMAIN-SUFFIX,apps.apple.com,$app_name
- DOMAIN-SUFFIX,itunes.apple.com,$app_name
- DOMAIN-SUFFIX,blobstore.apple.com,$app_name
- DOMAIN,safebrowsing.urlsec.qq.com,DIRECT
- DOMAIN-SUFFIX,apple.com,DIRECT
- DOMAIN-SUFFIX,apple-cloudkit.com,DIRECT
- DOMAIN-SUFFIX,icloud.com,DIRECT
- DOMAIN-SUFFIX,icloud-content.com,DIRECT
- DOMAIN-SUFFIX,mzstatic.com,DIRECT
- DOMAIN-SUFFIX,aaplimg.com,DIRECT
- DOMAIN-SUFFIX,cdn-apple.com,DIRECT
- DOMAIN-SUFFIX,akadns.net,DIRECT
# China direct (KEYWORD)
- DOMAIN-KEYWORD,baidu,DIRECT
- DOMAIN-KEYWORD,alibaba,DIRECT
- DOMAIN-KEYWORD,alicdn,DIRECT
- DOMAIN-KEYWORD,alipay,DIRECT
- DOMAIN-KEYWORD,taobao,DIRECT
- DOMAIN-KEYWORD,tencent,DIRECT
- DOMAIN-KEYWORD,bilibili,DIRECT
- DOMAIN-KEYWORD,weibo,DIRECT
- DOMAIN-KEYWORD,douyin,DIRECT
- DOMAIN-KEYWORD,bytedance,DIRECT
- DOMAIN-KEYWORD,xiaomi,DIRECT
- DOMAIN-KEYWORD,huawei,DIRECT
- DOMAIN-KEYWORD,netease,DIRECT
- DOMAIN-KEYWORD,meituan,DIRECT
- DOMAIN-KEYWORD,pinduoduo,DIRECT
- DOMAIN-KEYWORD,kuaishou,DIRECT
- DOMAIN-KEYWORD,jingdong,DIRECT
- DOMAIN-KEYWORD,officecdn,DIRECT
# China direct (SUFFIX)
- DOMAIN-SUFFIX,qq.com,DIRECT
- DOMAIN-SUFFIX,weixin.com,DIRECT
- DOMAIN-SUFFIX,wechat.com,DIRECT
- DOMAIN-SUFFIX,gtimg.com,DIRECT
- DOMAIN-SUFFIX,qcloud.com,DIRECT
- DOMAIN-SUFFIX,myqcloud.com,DIRECT
- DOMAIN-SUFFIX,qpic.cn,DIRECT
- DOMAIN-SUFFIX,tenpay.com,DIRECT
- DOMAIN-SUFFIX,tmall.com,DIRECT
- DOMAIN-SUFFIX,jd.com,DIRECT
- DOMAIN-SUFFIX,360buyimg.com,DIRECT
- DOMAIN-SUFFIX,iqiyi.com,DIRECT
- DOMAIN-SUFFIX,youku.com,DIRECT
- DOMAIN-SUFFIX,ykimg.com,DIRECT
- DOMAIN-SUFFIX,tudou.com,DIRECT
- DOMAIN-SUFFIX,acfun.tv,DIRECT
- DOMAIN-SUFFIX,hdslb.com,DIRECT
- DOMAIN-SUFFIX,sohu.com,DIRECT
- DOMAIN-SUFFIX,sogou.com,DIRECT
- DOMAIN-SUFFIX,zhihu.com,DIRECT
- DOMAIN-SUFFIX,zhimg.com,DIRECT
- DOMAIN-SUFFIX,douban.com,DIRECT
- DOMAIN-SUFFIX,doubanio.com,DIRECT
- DOMAIN-SUFFIX,163.com,DIRECT
- DOMAIN-SUFFIX,126.com,DIRECT
- DOMAIN-SUFFIX,126.net,DIRECT
- DOMAIN-SUFFIX,127.net,DIRECT
- DOMAIN-SUFFIX,yeah.net,DIRECT
- DOMAIN-SUFFIX,sina.com,DIRECT
- DOMAIN-SUFFIX,sinaimg.cn,DIRECT
- DOMAIN-SUFFIX,ximalaya.com,DIRECT
- DOMAIN-SUFFIX,xmcdn.com,DIRECT
- DOMAIN-SUFFIX,csdn.net,DIRECT
- DOMAIN-SUFFIX,gitee.com,DIRECT
- DOMAIN-SUFFIX,jianshu.com,DIRECT
- DOMAIN-SUFFIX,cnblogs.com,DIRECT
- DOMAIN-SUFFIX,oschina.net,DIRECT
- DOMAIN-SUFFIX,ele.me,DIRECT
- DOMAIN-SUFFIX,ctrip.com,DIRECT
- DOMAIN-SUFFIX,suning.com,DIRECT
- DOMAIN-SUFFIX,dianping.com,DIRECT
- DOMAIN-SUFFIX,amap.com,DIRECT
- DOMAIN-SUFFIX,autonavi.com,DIRECT
- 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-SUFFIX,globalsign.com,$app_name
- DOMAIN-SUFFIX,gmodules.com,$app_name
- DOMAIN-SUFFIX,godaddy.com,$app_name
- DOMAIN-SUFFIX,golang.org,$app_name
- DOMAIN-SUFFIX,gongm.in,$app_name
- DOMAIN-SUFFIX,goo.gl,$app_name
- DOMAIN-SUFFIX,goodreaders.com,$app_name
- DOMAIN-SUFFIX,goodreads.com,$app_name
- DOMAIN-SUFFIX,gravatar.com,$app_name
- DOMAIN-SUFFIX,gstatic.com,$app_name
- DOMAIN-SUFFIX,gvt0.com,$app_name
- DOMAIN-SUFFIX,hockeyapp.net,$app_name
- DOMAIN-SUFFIX,hotmail.com,$app_name
- DOMAIN-SUFFIX,icons8.com,$app_name
- DOMAIN-SUFFIX,ifixit.com,$app_name
- DOMAIN-SUFFIX,ift.tt,$app_name
- DOMAIN-SUFFIX,ifttt.com,$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-KEYWORD,blogspot,$app_name
- DOMAIN-KEYWORD,dropbox,$app_name
- DOMAIN-KEYWORD,wikipedia,$app_name
- DOMAIN-KEYWORD,pinterest,$app_name
- DOMAIN-KEYWORD,discord,$app_name
- DOMAIN-KEYWORD,openai,$app_name
- DOMAIN-KEYWORD,anthropic,$app_name
- DOMAIN-KEYWORD,netflix,$app_name
- DOMAIN-KEYWORD,spotify,$app_name
- DOMAIN-KEYWORD,amazon,$app_name
# Blocked services (SUFFIX)
- DOMAIN-SUFFIX,t.co,$app_name
- DOMAIN-SUFFIX,x.com,$app_name
- DOMAIN-SUFFIX,twimg.com,$app_name
- DOMAIN-SUFFIX,fb.me,$app_name
- DOMAIN-SUFFIX,fbcdn.net,$app_name
- DOMAIN-SUFFIX,youtu.be,$app_name
- DOMAIN-SUFFIX,ytimg.com,$app_name
# Telegram
- DOMAIN-SUFFIX,telegra.ph,$app_name
- DOMAIN-SUFFIX,telegram.org,$app_name
- DOMAIN-SUFFIX,gstatic.com,$app_name
- DOMAIN-SUFFIX,ggpht.com,$app_name
- DOMAIN-SUFFIX,googlevideo.com,$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.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.56.0/22,$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:b28:f23d::/48,$app_name,no-resolve
- IP-CIDR6,2001:b28:f23f::/48,$app_name,no-resolve
# 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
# 最终规则
# Fallback
- GEOIP,CN,DIRECT
- MATCH,$app_name
+63 -6
View File
@@ -14,12 +14,69 @@
secure_path: "{{ $secure_path }}",
};
</script>
<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>
@php
$manifestPath = public_path('assets/admin/manifest.json');
$manifest = file_exists($manifestPath) ? json_decode(file_get_contents($manifestPath), true) : null;
$entry = is_array($manifest) ? ($manifest['index.html'] ?? null) : null;
$scripts = [];
$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>
<body>

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