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