merge: sync upstream/master preserving local changes

This commit is contained in:
yinjianm
2026-04-23 22:27:18 +08:00
94 changed files with 14065 additions and 650 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ class CheckTicket extends Command
{
Ticket::where('status', 0)
->where('updated_at', '<=', time() - 24 * 3600)
->where('reply_status', 0)
->where('reply_status', Ticket::REPLY_STATUS_REPLIED)
->lazyById(200)
->each(function ($ticket) {
if ($ticket->user_id === $ticket->last_reply_user_id) return;
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class CleanupOnlineStatus extends Command
{
protected $signature = 'cleanup:online-status';
protected $description = 'Reset stale online_count for users whose devices have expired from Redis';
public function handle(): void
{
$affected = User::where('online_count', '>', 0)
->where(function ($query) {
$query->where('last_online_at', '<', now()->subMinutes(10))
->orWhereNull('last_online_at');
})
->update(['online_count' => 0]);
if ($affected > 0) {
$this->info("Reset online_count for {$affected} stale users.");
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ class HookList extends Command
public function handle()
{
$paths = [base_path('app'), base_path('plugins')];
$paths = [base_path('app'), base_path('plugins-core'), base_path('plugins')];
$hooks = collect();
$pattern = '/HookManager::(call|filter|register|registerFilter)\([\'\"]([a-zA-Z0-9_.-]+)[\'\"]/';
+26 -38
View File
@@ -16,8 +16,6 @@ use function Laravel\Prompts\confirm;
use function Laravel\Prompts\text;
use function Laravel\Prompts\note;
use function Laravel\Prompts\select;
use App\Models\Plugin;
use Illuminate\Support\Str;
class XboardInstall extends Command
{
@@ -103,15 +101,17 @@ class XboardInstall extends Command
$isReidsValid = false;
while (!$isReidsValid) {
// 判断是否为Docker环境
if ($isDocker == 'true' && ($enableRedis || confirm(label: '是否启用Docker内置的Redis', default: true, yes: '启用', no: '不启用'))) {
$useBuiltinRedis = $isDocker && ($enableRedis || confirm(label: '是否启用Docker内置的Redis', default: true, yes: '启用', no: '不启用'));
if ($useBuiltinRedis) {
$envConfig['REDIS_HOST'] = '/data/redis.sock';
$envConfig['REDIS_PORT'] = 0;
$envConfig['REDIS_PASSWORD'] = null;
} else {
$envConfig['REDIS_HOST'] = text(label: '请输入Redis地址', default: '127.0.0.1', required: true);
$envConfig['REDIS_PORT'] = text(label: '请输入Redis端口', default: '6379', required: true);
$envConfig['REDIS_PASSWORD'] = text(label: '请输入redis密码(默认: null)', default: '');
$isReidsValid = true;
break;
}
$envConfig['REDIS_HOST'] = text(label: '请输入Redis地址', default: '127.0.0.1', required: true);
$envConfig['REDIS_PORT'] = text(label: '请输入Redis端口', default: '6379', required: true);
$envConfig['REDIS_PASSWORD'] = text(label: '请输入redis密码(默认: null)', default: '');
$redisConfig = [
'client' => 'phpredis',
'default' => [
@@ -150,6 +150,20 @@ class XboardInstall extends Command
$password = Helper::guid(false);
$this->saveToEnv($envConfig);
$installDriverOverrides = [
'CACHE_DRIVER' => 'array',
'QUEUE_CONNECTION' => 'sync',
'SESSION_DRIVER' => 'array',
];
foreach ($installDriverOverrides as $key => $value) {
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
Config::set('cache.default', 'array');
Config::set('queue.default', 'sync');
Config::set('session.driver', 'array');
$this->call('config:cache');
Artisan::call('cache:clear');
$this->info('正在导入数据库请稍等...');
@@ -160,7 +174,6 @@ class XboardInstall extends Command
if (!self::registerAdmin($email, $password)) {
abort(500, '管理员账号注册失败,请重试');
}
self::restoreProtectedPlugins($this);
$this->info('正在安装默认插件...');
PluginManager::installDefaultPlugins();
$this->info('默认插件安装完成');
@@ -173,6 +186,11 @@ class XboardInstall extends Command
$this->info("访问 http(s)://你的站点/{$defaultSecurePath} 进入管理面板,你可以在用户中心修改你的密码。");
$envConfig['INSTALLED'] = true;
$this->saveToEnv($envConfig);
foreach (array_keys($installDriverOverrides) as $key) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
}
Artisan::call('config:clear');
} catch (\Exception $e) {
$this->error($e);
}
@@ -364,34 +382,4 @@ class XboardInstall extends Command
}
}
}
/**
* 还原内置受保护插件(可在安装和更新时调用)
* Docker 部署时 plugins/ 目录被外部挂载覆盖,需要从镜像备份中还原默认插件
*/
public static function restoreProtectedPlugins(Command $console = null)
{
$backupBase = '/opt/default-plugins';
$pluginsBase = base_path('plugins');
if (!File::isDirectory($backupBase)) {
$console?->info('非 Docker 环境或备份目录不存在,跳过插件还原。');
return;
}
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
$dirName = Str::studly($pluginCode);
$source = "{$backupBase}/{$dirName}";
$target = "{$pluginsBase}/{$dirName}";
if (!File::isDirectory($source)) {
continue;
}
// 先清除旧文件再复制,避免重命名后残留旧文件
File::deleteDirectory($target);
File::copyDirectory($source, $target);
$console?->info("已同步默认插件 [{$dirName}]");
}
}
}
+9 -9
View File
@@ -7,9 +7,6 @@ use App\Services\UpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use App\Services\Plugin\PluginManager;
use App\Models\Plugin;
use Illuminate\Support\Str;
use App\Console\Commands\XboardInstall;
class XboardUpdate extends Command
{
@@ -47,19 +44,22 @@ class XboardUpdate extends Command
$this->info('正在导入数据库请稍等...');
Artisan::call("migrate", ['--force' => true]);
$this->info(Artisan::output());
$this->info('正在检查内置插件文件...');
XboardInstall::restoreProtectedPlugins($this);
$this->info('正在检查并安装默认插件...');
PluginManager::installDefaultPlugins();
$this->info('默认插件检查完成');
// Artisan::call('reset:traffic', ['--fix-null' => true]);
$this->info('正在重新计算所有用户的重置时间...');
Artisan::call('reset:traffic', ['--force' => true]);
$updateService = new UpdateService();
$updateService->updateVersionCache();
$themeService = app(ThemeService::class);
$themeService->refreshCurrentTheme();
Artisan::call('horizon:terminate');
if (config('queue.default') === 'sync') {
$this->info('horizon:terminate skipped (sync queue, no workers to terminate).');
} else {
try {
Artisan::call('horizon:terminate');
} catch (\Throwable $e) {
$this->warn('horizon:terminate skipped: ' . $e->getMessage());
}
}
$this->info('更新完毕,队列服务已重启,你无需进行任何操作。');
}
}
+2
View File
@@ -42,6 +42,8 @@ class Kernel extends ConsoleKernel
$schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer();
// horizon metrics
$schedule->command('horizon:snapshot')->everyFiveMinutes()->onOneServer();
// cleanup stale online_count (GC for Redis TTL expiration)
$schedule->command('cleanup:online-status')->everyFiveMinutes()->onOneServer();
// backup Timing
// if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) {
// $schedule->command('backup:database', ['true'])->daily()->onOneServer();
@@ -178,7 +178,6 @@ class ConfigController extends Controller
'server_ws_url' => admin_setting('server_ws_url', ''),
],
'email' => [
'email_template' => admin_setting('email_template', 'default'),
'email_host' => admin_setting('email_host'),
'email_port' => admin_setting('email_port'),
'email_username' => admin_setting('email_username'),
@@ -0,0 +1,266 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\MailTemplate;
use App\Services\MailService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class MailTemplateController extends Controller
{
public function list()
{
$dbTemplates = MailTemplate::all()->keyBy('name');
$result = [];
foreach (MailTemplate::TEMPLATES as $name => $meta) {
$db = $dbTemplates->get($name);
$result[] = [
'name' => $name,
'label' => $meta['label'],
'customized' => $db !== null,
'subject' => $db?->subject,
'updated_at' => $db?->updated_at?->timestamp,
];
}
return $this->success($result);
}
public function get(Request $request)
{
$name = $request->input('name');
$meta = MailTemplate::getMeta($name);
if (!$meta) {
return $this->fail([404, '模板不存在']);
}
$db = MailTemplate::where('name', $name)->first();
return $this->success([
'name' => $name,
'label' => $meta['label'],
'required_vars' => $meta['required_vars'],
'optional_vars' => $meta['optional_vars'],
'customized' => $db !== null,
'subject' => $db?->subject ?? $this->getDefaultSubject($name),
'content' => $db?->content ?? $this->getDefaultContent($name),
]);
}
public function save(Request $request)
{
$params = $request->validate([
'name' => 'required|string',
'subject' => 'required|string|max:255',
'content' => 'required|string',
]);
$meta = MailTemplate::getMeta($params['name']);
if (!$meta) {
return $this->fail([404, '模板不存在']);
}
$errors = MailTemplate::validateContent($params['name'], $params['content']);
if (!empty($errors)) {
return $this->fail([422, implode('; ', $errors)]);
}
MailTemplate::updateOrCreate(
['name' => $params['name']],
['subject' => $params['subject'], 'content' => $params['content']]
);
Cache::forget("mail_template:{$params['name']}");
return $this->success(true);
}
public function reset(Request $request)
{
$name = $request->input('name');
$meta = MailTemplate::getMeta($name);
if (!$meta) {
return $this->fail([404, '模板不存在']);
}
MailTemplate::where('name', $name)->delete();
Cache::forget("mail_template:{$name}");
return $this->success(true);
}
public function test(Request $request)
{
$name = $request->input('name');
$meta = MailTemplate::getMeta($name);
if (!$meta) {
return $this->fail([404, '模板不存在']);
}
$email = $request->input('email', $request->user()->email);
$testVars = $this->getTestVars($name);
try {
$log = MailService::sendEmail([
'email' => $email,
'subject' => $this->getTestSubject($name),
'template_name' => $name,
'template_value' => $testVars,
]);
if ($log['error']) {
return $this->fail([500, '发送失败: ' . $log['error']]);
}
return $this->success(true);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([500, '发送失败: ' . $e->getMessage()]);
}
}
private function getTestSubject(string $name): string
{
$appName = admin_setting('app_name', 'XBoard');
return match ($name) {
'verify' => "{$appName} - 验证码测试",
'notify' => "{$appName} - 通知测试",
'remindExpire' => "{$appName} - 到期提醒测试",
'remindTraffic' => "{$appName} - 流量提醒测试",
'mailLogin' => "{$appName} - 登录链接测试",
default => "{$appName} - 邮件测试",
};
}
private function getTestVars(string $name): array
{
$appName = admin_setting('app_name', 'XBoard');
$appUrl = admin_setting('app_url', 'https://example.com');
return match ($name) {
'verify' => [
'name' => $appName,
'code' => '123456',
'url' => $appUrl,
],
'notify' => [
'name' => $appName,
'content' => '这是一封测试通知邮件。',
'url' => $appUrl,
],
'remindExpire' => [
'name' => $appName,
'url' => $appUrl,
],
'remindTraffic' => [
'name' => $appName,
'url' => $appUrl,
],
'mailLogin' => [
'name' => $appName,
'link' => $appUrl . '/login?token=test-token',
'url' => $appUrl,
],
default => ['name' => $appName, 'url' => $appUrl],
};
}
private function getDefaultSubject(string $name): string
{
$appName = admin_setting('app_name', 'XBoard');
return match ($name) {
'verify' => "{$appName} - 邮箱验证码",
'notify' => "{$appName} - 站点通知",
'remindExpire' => "{$appName} - 服务即将到期",
'remindTraffic' => "{$appName} - 流量使用提醒",
'mailLogin' => "{$appName} - 邮件登录",
default => "{$appName}",
};
}
private function getDefaultContent(string $name): string
{
$theme = 'default';
$viewName = "mail.{$theme}.{$name}";
try {
$viewPath = resource_path("views/mail/{$theme}/{$name}.blade.php");
if (file_exists($viewPath)) {
$blade = file_get_contents($viewPath);
return self::bladeToPlaceholder($blade);
}
} catch (\Throwable $e) {
// ignore
}
return self::hardcodedDefault($name);
}
/**
* Convert Blade syntax to {{placeholder}} syntax for editing.
*/
private static function bladeToPlaceholder(string $blade): string
{
// {{$var}} → {{var}}
$result = preg_replace('/\{\{\s*\$([a-zA-Z_]+)\s*\}\}/', '{{$1}}', $blade);
// {!! nl2br($var) !!} → {{var}}
$result = preg_replace('/\{!!\s*nl2br\(\$([a-zA-Z_]+)\)\s*!!\}/', '{{$1}}', $result);
// {!! $var !!} → {{var}}
$result = preg_replace('/\{!!\s*\$([a-zA-Z_]+)\s*!!\}/', '{{$1}}', $result);
return $result;
}
private static function hardcodedDefault(string $name): string
{
$layout = fn($title, $body) => <<<HTML
<div style="background: #eee">
<table width="600" border="0" align="center" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<div style="background:#fff">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<thead>
<tr>
<td valign="middle" style="padding-left:30px;background-color:#415A94;color:#fff;padding:20px 40px;font-size: 21px;">{{name}}</td>
</tr>
</thead>
<tbody>
<tr style="padding:40px 40px 0 40px;display:table-cell">
<td style="font-size:24px;line-height:1.5;color:#000;margin-top:40px">{$title}</td>
</tr>
<tr>
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
尊敬的用户您好!<br /><br />{$body}
</td>
</tr>
</tbody>
</table>
</div>
<div>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tbody>
<tr>
<td style="padding:20px 40px;font-size:12px;color:#999;line-height:20px;background:#f7f7f7"><a href="{{url}}" style="font-size:14px;color:#929292">返回{{name}}</a></td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
HTML;
return match ($name) {
'verify' => $layout('邮箱验证码', '您的验证码是:{{code}},请在 5 分钟内进行验证。如果该验证码不为您本人申请,请无视。'),
'notify' => $layout('网站通知', '{{content}}'),
'remindExpire' => $layout('服务到期提醒', '您的服务即将在24小时内到期,如需继续使用请及时续费。'),
'remindTraffic' => $layout('流量使用提醒', '您的流量使用已达到80%,请注意流量使用情况。'),
'mailLogin' => $layout('登入到{{name}}', '您正在登入到{{name}}, 请在 5 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。<a href="{{link}}">{{link}}</a>'),
default => $layout('通知', '{{content}}'),
};
}
}
@@ -25,7 +25,7 @@ class PaymentController extends Controller
public function fetch()
{
$payments = Payment::orderBy('sort', 'ASC')->get();
$payments = Payment::orderBy('sort', 'ASC')->get()->makeVisible('config');
foreach ($payments as $k => $v) {
$notifyUrl = url("/api/v1/guest/payment/notify/{$v->payment}/{$v->uuid}");
if ($v->notify_domain) {
@@ -60,56 +60,67 @@ class PluginController extends Controller
->keyBy('code')
->toArray();
$pluginPath = base_path('plugins');
$plugins = [];
$seenCodes = [];
if (File::exists($pluginPath)) {
foreach ($this->pluginManager->getPluginPaths() as $pluginPath) {
if (!File::exists($pluginPath)) {
continue;
}
$directories = File::directories($pluginPath);
foreach ($directories as $directory) {
$pluginName = basename($directory);
$configFile = $directory . '/config.json';
if (File::exists($configFile)) {
$config = json_decode(File::get($configFile), true);
$code = $config['code'];
$pluginType = $config['type'] ?? Plugin::TYPE_FEATURE;
// 如果指定了类型,过滤插件
if ($type && $pluginType !== $type) {
continue;
}
$installed = isset($installedPlugins[$code]);
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
$readmeFile = collect(['README.md', 'readme.md'])
->map(fn($f) => $directory . '/' . $f)
->first(fn($path) => File::exists($path));
$readmeContent = $readmeFile ? File::get($readmeFile) : '';
$needUpgrade = false;
if ($installed) {
$installedVersion = $installedPlugins[$code]['version'] ?? null;
$localVersion = $config['version'] ?? null;
if ($installedVersion && $localVersion && version_compare($localVersion, $installedVersion, '>')) {
$needUpgrade = true;
}
}
$plugins[] = [
'code' => $config['code'],
'name' => $config['name'],
'version' => $config['version'],
'description' => $config['description'],
'author' => $config['author'],
'type' => $pluginType,
'is_installed' => $installed,
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
'is_protected' => in_array($code, Plugin::PROTECTED_PLUGINS),
'can_be_deleted' => !in_array($code, Plugin::PROTECTED_PLUGINS),
'config' => $pluginConfig,
'readme' => $readmeContent,
'need_upgrade' => $needUpgrade,
'admin_menus' => $config['admin_menus'] ?? null,
'admin_crud' => $config['admin_crud'] ?? null,
];
if (!File::exists($configFile)) {
continue;
}
$config = json_decode(File::get($configFile), true);
if (!$config || !isset($config['code'])) {
continue;
}
$code = $config['code'];
if (isset($seenCodes[$code])) {
continue;
}
$seenCodes[$code] = true;
$pluginType = $config['type'] ?? Plugin::TYPE_FEATURE;
if ($type && $pluginType !== $type) {
continue;
}
$installed = isset($installedPlugins[$code]);
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
$readmeFile = collect(['README.md', 'readme.md'])
->map(fn($f) => $directory . '/' . $f)
->first(fn($path) => File::exists($path));
$readmeContent = $readmeFile ? File::get($readmeFile) : '';
$needUpgrade = false;
if ($installed) {
$installedVersion = $installedPlugins[$code]['version'] ?? null;
$localVersion = $config['version'] ?? null;
if ($installedVersion && $localVersion && version_compare($localVersion, $installedVersion, '>')) {
$needUpgrade = true;
}
}
$isCore = $this->pluginManager->isCorePlugin($code);
$plugins[] = [
'code' => $config['code'],
'name' => $config['name'],
'version' => $config['version'],
'description' => $config['description'],
'author' => $config['author'],
'type' => $pluginType,
'is_installed' => $installed,
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
'is_protected' => $isCore,
'can_be_deleted' => !$isCore,
'config' => $pluginConfig,
'readme' => $readmeContent,
'need_upgrade' => $needUpgrade,
'admin_menus' => $config['admin_menus'] ?? null,
'admin_crud' => $config['admin_crud'] ?? null,
];
}
}
@@ -314,10 +325,10 @@ class PluginController extends Controller
$code = $request->input('code');
// 检查是否为受保护的插件
if (in_array($code, Plugin::PROTECTED_PLUGINS)) {
// 检查是否为核心插件
if ($this->pluginManager->isCorePlugin($code)) {
return response()->json([
'message' => '该插件为系统默认插件,不允许删除'
'message' => '该插件为系统核心插件,不允许删除'
], 403);
}
@@ -168,12 +168,19 @@ class MachineController extends Controller
$params = $request->validate([
'machine_id' => 'required|integer|exists:v2_server_machine,id',
'limit' => 'nullable|integer|min:10|max:1440',
'range_hours' => 'nullable|integer|min:1|max:24',
]);
$query = ServerMachineLoadHistory::query()
->where('machine_id', $params['machine_id']);
if (!empty($params['range_hours'])) {
$query->where('recorded_at', '>=', now()->subHours((int) $params['range_hours'])->timestamp);
}
$limit = (int) ($params['limit'] ?? 60);
$history = ServerMachineLoadHistory::query()
->where('machine_id', $params['machine_id'])
$history = $query
->orderByDesc('recorded_at')
->limit($limit)
->get([
@@ -182,6 +189,8 @@ class MachineController extends Controller
'mem_used',
'disk_total',
'disk_used',
'net_in_speed',
'net_out_speed',
'recorded_at',
])
->reverse()
@@ -225,6 +225,8 @@ class ManageController extends Controller
'ids' => 'required|array',
'ids.*' => 'integer',
'show' => 'nullable|integer|in:0,1',
'enabled' => 'nullable|boolean',
'machine_id' => 'nullable|integer',
]);
$ids = $params['ids'];
@@ -236,13 +238,25 @@ class ManageController extends Controller
if (array_key_exists('show', $params) && $params['show'] !== null) {
$update['show'] = (int) $params['show'];
}
if (array_key_exists('enabled', $params) && $params['enabled'] !== null) {
$update['enabled'] = (bool) $params['enabled'];
}
if (array_key_exists('machine_id', $params)) {
$update['machine_id'] = $params['machine_id'] ?: null;
}
if (empty($update)) {
return $this->fail([400, '没有可更新的字段']);
}
try {
Server::whereIn('id', $ids)->update($update);
$servers = Server::whereIn('id', $ids)->get();
DB::transaction(function () use ($servers, $update) {
/** @var Server $server */
foreach ($servers as $server) {
$server->update($update);
}
});
return $this->success(true);
} catch (\Exception $e) {
Log::error($e);
@@ -55,6 +55,7 @@ class TicketController extends Controller
if (!$ticket) {
return $this->fail([400202, '工单不存在']);
}
$ticket->messages->each(fn($msg) => $msg->setRelation('ticket', $ticket));
$result = $ticket->toArray();
$result['user'] = UserController::transformUserData($ticket->user);
@@ -144,11 +145,12 @@ class TicketController extends Controller
$ticket = Ticket::with([
'user',
'messages' => function ($query) {
$query->with(['user']); // 如果需要用户信息
$query->with(['user']);
}
])->findOrFail($ticketId);
// 自动包含 is_me 属性
$ticket->messages->each(fn($msg) => $msg->setRelation('ticket', $ticket));
return response()->json([
'data' => $ticket
]);
@@ -50,32 +50,46 @@ class MachineController extends Controller
'swap.used' => 'nullable|integer|min:0',
'disk.total' => 'nullable|integer|min:0',
'disk.used' => 'nullable|integer|min:0',
'net.in_speed' => 'nullable|numeric|min:0',
'net.out_speed' => 'nullable|numeric|min:0',
]);
$machine = $this->authenticateMachine($request);
$recordedAt = now()->timestamp;
$machine->forceFill([
'load_status' => [
'cpu' => (float) $request->input('cpu'),
'mem' => [
'total' => (int) $request->input('mem.total'),
'used' => (int) $request->input('mem.used'),
],
'swap' => [
'total' => (int) $request->input('swap.total', 0),
'used' => (int) $request->input('swap.used', 0),
],
'disk' => [
'total' => (int) $request->input('disk.total', 0),
'used' => (int) $request->input('disk.used', 0),
],
'updated_at' => $recordedAt,
$loadStatus = [
'cpu' => (float) $request->input('cpu'),
'mem' => [
'total' => (int) $request->input('mem.total'),
'used' => (int) $request->input('mem.used'),
],
'swap' => [
'total' => (int) $request->input('swap.total', 0),
'used' => (int) $request->input('swap.used', 0),
],
'disk' => [
'total' => (int) $request->input('disk.total', 0),
'used' => (int) $request->input('disk.used', 0),
],
'updated_at' => $recordedAt,
];
$netInSpeed = $request->input('net.in_speed');
$netOutSpeed = $request->input('net.out_speed');
if ($netInSpeed !== null && $netOutSpeed !== null) {
$loadStatus['net'] = [
'in_speed' => (float) $netInSpeed,
'out_speed' => (float) $netOutSpeed,
];
}
$machine->forceFill([
'load_status' => $loadStatus,
'last_seen_at' => $recordedAt,
])->save();
ServerMachineLoadHistory::create([
$historyData = [
'machine_id' => $machine->id,
'cpu' => (float) $request->input('cpu'),
'mem_total' => (int) $request->input('mem.total'),
@@ -83,7 +97,14 @@ class MachineController extends Controller
'disk_total' => (int) $request->input('disk.total', 0),
'disk_used' => (int) $request->input('disk.used', 0),
'recorded_at' => $recordedAt,
]);
];
if ($netInSpeed !== null && $netOutSpeed !== null) {
$historyData['net_in_speed'] = (float) $netInSpeed;
$historyData['net_out_speed'] = (float) $netOutSpeed;
}
ServerMachineLoadHistory::create($historyData);
// Time-based cleanup: keep 24h of data, runs on ~5% of requests
if (random_int(1, 20) === 1) {
@@ -4,8 +4,10 @@ namespace App\Http\Controllers\V2\Server;
use App\Http\Controllers\Controller;
use App\Services\ServerService;
use App\WebSocket\NodeWorker;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
class ServerController extends Controller
{
@@ -16,14 +18,14 @@ class ServerController extends Controller
{
$websocket = ['enabled' => false];
if ((bool) admin_setting('server_ws_enable', 1)) {
if ((bool) admin_setting('server_ws_enable', 1) && Cache::has(NodeWorker::HEARTBEAT_CACHE_KEY)) {
$customUrl = trim((string) admin_setting('server_ws_url', ''));
if ($customUrl !== '') {
$wsUrl = rtrim($customUrl, '/');
} else {
$wsScheme = $request->isSecure() ? 'wss' : 'ws';
$wsUrl = "{$wsScheme}://{$request->getHost()}:8076";
$wsUrl = "{$wsScheme}://{$request->getHttpHost()}/ws";
}
$websocket = [
+1
View File
@@ -77,6 +77,7 @@ class Kernel extends HttpKernel
'staff' => \App\Http\Middleware\Staff::class,
'log' => \App\Http\Middleware\RequestLog::class,
'server' => \App\Http\Middleware\Server::class,
'server.v2' => \App\Http\Middleware\ServerV2::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
];
+4 -65
View File
@@ -4,29 +4,16 @@ namespace App\Http\Middleware;
use App\Exceptions\ApiException;
use App\Models\Server as ServerModel;
use App\Models\ServerMachine;
use App\Services\ServerService;
use Closure;
use Illuminate\Http\Request;
/**
* @deprecated use {@see ServerV2}
*/
class Server
{
public function handle(Request $request, Closure $next, ?string $nodeType = null)
{
// 优先尝试 machine token 认证,兜底走旧的 server token 认证
if ($request->filled('machine_id')) {
$this->authenticateByMachine($request, $nodeType);
} else {
$this->authenticateByServerToken($request, $nodeType);
}
return $next($request);
}
/**
* 旧模式:全局 server_token + node_id
*/
private function authenticateByServerToken(Request $request, ?string $nodeType): void
{
$request->validate([
'token' => [
@@ -64,55 +51,7 @@ class Server
}
$request->attributes->set('node_info', $serverInfo);
}
/**
* 新模式:machine_id + machine token + node_id
*
* machine 认证后,node_id 必须属于该 machine 下的已启用节点。
* 下游控制器拿到的 node_info 与旧模式完全一致。
*/
private function authenticateByMachine(Request $request, ?string $nodeType): void
{
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
$request->validate([
'machine_id' => 'required|integer',
'token' => 'required|string',
'node_id' => $isHandshake ? 'nullable|integer' : 'required|integer',
]);
$machine = ServerMachine::where('id', $request->input('machine_id'))
->where('token', $request->input('token'))
->first();
if (!$machine) {
throw new ApiException('Machine not found or invalid token', 401);
}
if (!$machine->is_active) {
throw new ApiException('Machine is disabled', 403);
}
$nodeId = (int) $request->input('node_id');
$serverInfo = null;
if ($nodeId > 0) {
$serverInfo = ServerModel::where('id', $nodeId)
->where('machine_id', $machine->id)
->where('enabled', true)
->first();
if (!$serverInfo) {
throw new ApiException('Node not found on this machine');
}
$request->attributes->set('node_info', $serverInfo);
}
// 更新机器心跳
$machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly();
$request->attributes->set('machine_info', $machine);
return $next($request);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Middleware;
use App\Exceptions\ApiException;
use App\Models\Server as ServerModel;
use App\Models\ServerMachine;
use App\Services\ServerService;
use Closure;
use Illuminate\Http\Request;
/**
* V2 server middleware: machine-token or server-token auth, no node_type.
*/
class ServerV2
{
public function handle(Request $request, Closure $next)
{
if ($request->filled('machine_id')) {
$this->authenticateByMachine($request);
} else {
$this->authenticateByServerToken($request);
}
return $next($request);
}
private function authenticateByServerToken(Request $request): void
{
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
$request->validate([
'token' => [
'string', 'required',
function ($attribute, $value, $fail) {
if ($value !== admin_setting('server_token')) {
$fail("Invalid {$attribute}");
}
},
],
'node_id' => $isHandshake ? 'nullable' : 'required',
]);
$nodeId = $request->input('node_id');
if ($nodeId === null || $nodeId === '') {
return;
}
$serverInfo = ServerService::getServer($nodeId);
if (!$serverInfo) {
throw new ApiException('Server does not exist');
}
$request->attributes->set('node_info', $serverInfo);
}
private function authenticateByMachine(Request $request): void
{
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
$request->validate([
'machine_id' => 'required|integer',
'token' => 'required|string',
'node_id' => $isHandshake ? 'nullable|integer' : 'required|integer',
]);
$machine = ServerMachine::where('id', $request->input('machine_id'))
->where('token', $request->input('token'))
->first();
if (!$machine) {
throw new ApiException('Machine not found or invalid token', 401);
}
if (!$machine->is_active) {
throw new ApiException('Machine is disabled', 403);
}
$nodeId = (int) $request->input('node_id');
if ($nodeId > 0) {
$serverInfo = ServerModel::where('id', $nodeId)
->where('machine_id', $machine->id)
->where('enabled', true)
->first();
if (!$serverInfo) {
throw new ApiException('Node not found on this machine');
}
$request->attributes->set('node_info', $serverInfo);
}
$machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly();
$request->attributes->set('machine_info', $machine);
}
}
-1
View File
@@ -60,7 +60,6 @@ class ConfigSave extends FormRequest
'frontend_theme_color' => 'nullable|in:default,darkblue,black,green',
'frontend_background_url' => 'nullable|url',
// email
'email_template' => '',
'email_host' => '',
'email_port' => '',
'email_username' => '',
+11
View File
@@ -54,6 +54,7 @@ class ServerSave extends FormRequest
'tls' => 'required|integer',
'network' => 'required|string',
'network_settings' => 'nullable|array',
'rules' => 'nullable|array',
],
'trojan' => [
'tls' => 'nullable|integer',
@@ -91,6 +92,12 @@ class ServerSave extends FormRequest
'http' => [
'tls' => 'required|integer',
],
'tuic' => [
'version' => 'nullable|integer',
'congestion_control' => 'nullable|string',
'alpn' => 'nullable|array',
'udp_relay_mode' => 'nullable|string',
],
'mieru' => [
'transport' => 'required|string|in:TCP,UDP',
'traffic_pattern' => 'string',
@@ -161,6 +168,10 @@ class ServerSave extends FormRequest
$rules,
$this->buildTlsObjectRules(),
),
'mieru' => array_merge(
$rules,
self::MULTIPLEX_RULES,
),
'vless' => array_merge(
$rules,
$this->buildTlsSettingsRules(),
+6
View File
@@ -23,6 +23,12 @@ class OrderResource extends JsonResource
...parent::toArray($request),
'period' => PlanService::getLegacyPeriod((string)$this->period),
'plan' => $this->whenLoaded('plan', fn() => PlanResource::make($this->plan)),
'payment' => $this->whenLoaded('payment', fn() => $this->payment ? [
'id' => $this->payment->id,
'name' => $this->payment->name,
'payment' => $this->payment->payment,
'icon' => $this->payment->icon,
] : null),
];
}
}
+12
View File
@@ -2,6 +2,7 @@
namespace App\Http\Routes\V2;
use App\Http\Controllers\V2\Admin\ConfigController;
use App\Http\Controllers\V2\Admin\MailTemplateController;
use App\Http\Controllers\V2\Admin\PlanController;
use App\Http\Controllers\V2\Admin\Server\GroupController;
use App\Http\Controllers\V2\Admin\Server\RouteController;
@@ -41,6 +42,17 @@ class AdminRoute
$router->post('/testSendMail', [ConfigController::class, 'testSendMail']);
});
// Mail Templates
$router->group([
'prefix' => 'mail/template'
], function ($router) {
$router->get('/list', [MailTemplateController::class, 'list']);
$router->get('/get', [MailTemplateController::class, 'get']);
$router->post('/save', [MailTemplateController::class, 'save']);
$router->post('/reset', [MailTemplateController::class, 'reset']);
$router->post('/test', [MailTemplateController::class, 'test']);
});
// Plan
$router->group([
'prefix' => 'plan'
+1 -1
View File
@@ -14,7 +14,7 @@ class ServerRoute
{
$router->group([
'prefix' => 'server',
'middleware' => 'server'
'middleware' => 'server.v2'
], function ($route) {
$route->match(['GET', 'POST'], 'handshake', [ServerController::class, 'handshake']);
$route->post('report', [ServerController::class, 'report']);
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class MailTemplate extends Model
{
protected $table = 'v2_mail_templates';
protected $fillable = ['name', 'subject', 'content'];
/**
* Template definitions: required/optional vars and default content.
*/
public const TEMPLATES = [
'verify' => [
'label' => '邮箱验证码',
'required_vars' => ['code'],
'optional_vars' => ['name', 'url'],
],
'notify' => [
'label' => '站点通知',
'required_vars' => ['content'],
'optional_vars' => ['name', 'url'],
],
'remindExpire' => [
'label' => '到期提醒',
'required_vars' => [],
'optional_vars' => ['name', 'url'],
],
'remindTraffic' => [
'label' => '流量提醒',
'required_vars' => [],
'optional_vars' => ['name', 'url'],
],
'mailLogin' => [
'label' => '邮件登录',
'required_vars' => ['link'],
'optional_vars' => ['name', 'url'],
],
];
/**
* Get template metadata (vars, label) for a given template name.
*/
public static function getMeta(string $name): ?array
{
return self::TEMPLATES[$name] ?? null;
}
/**
* Get all template names.
*/
public static function getNames(): array
{
return array_keys(self::TEMPLATES);
}
/**
* Validate that required placeholders are present in the content.
*/
public static function validateContent(string $name, string $content): array
{
$meta = self::getMeta($name);
if (!$meta) {
return ["Unknown template: {$name}"];
}
$errors = [];
foreach ($meta['required_vars'] as $var) {
if (strpos($content, '{{' . $var . '}}') === false) {
$errors[] = "缺少必要占位符: {{{$var}}}";
}
}
return $errors;
}
}
+4
View File
@@ -15,4 +15,8 @@ class Payment extends Model
'config' => 'array',
'enable' => 'boolean'
];
protected $hidden = [
'config',
];
}
+2
View File
@@ -17,6 +17,8 @@ class ServerMachineLoadHistory extends Model
'mem_used' => 'integer',
'disk_total' => 'integer',
'disk_used' => 'integer',
'net_in_speed' => 'float',
'net_out_speed' => 'float',
'recorded_at' => 'integer',
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
+3
View File
@@ -39,6 +39,9 @@ class Ticket extends Model
self::STATUS_CLOSED => '关闭'
];
const REPLY_STATUS_WAITING = 0;
const REPLY_STATUS_REPLIED = 1;
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
+3 -2
View File
@@ -29,6 +29,7 @@ class TicketMessage extends Model
];
protected $appends = ['is_from_user', 'is_from_admin'];
protected $hidden = ['ticket'];
/**
* 关联的工单
@@ -43,7 +44,7 @@ class TicketMessage extends Model
*/
public function getIsFromUserAttribute(): bool
{
return $this->ticket->user_id === $this->user_id;
return $this->ticket && $this->ticket->user_id === $this->user_id;
}
/**
@@ -51,6 +52,6 @@ class TicketMessage extends Model
*/
public function getIsFromAdminAttribute(): bool
{
return $this->ticket->user_id !== $this->user_id;
return $this->ticket && $this->ticket->user_id !== $this->user_id;
}
}
+2 -2
View File
@@ -238,10 +238,10 @@ class Clash extends AbstractProtocol
$array['port'] = $server['port'];
$array['password'] = $password;
$array['udp'] = true;
if ($serverName = data_get($protocol_settings, 'server_name')) {
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['sni'] = $serverName;
}
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure');
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
+35 -2
View File
@@ -36,6 +36,27 @@ class ClashMeta extends AbstractProtocol
'http' => '0.0.0',
'h2' => '0.0.0',
'httpupgrade' => '0.0.0',
'xhttp' => '0.0.0',
],
'strict' => true,
],
'*.vmess.protocol_settings.network' => [
'whitelist' => [
'tcp' => '0.0.0',
'ws' => '0.0.0',
'grpc' => '0.0.0',
'http' => '0.0.0',
'h2' => '0.0.0',
'httpupgrade' => '0.0.0',
],
'strict' => true,
],
'*.trojan.protocol_settings.network' => [
'whitelist' => [
'tcp' => '0.0.0',
'ws' => '0.0.0',
'grpc' => '0.0.0',
'httpupgrade' => '0.0.0',
],
'strict' => true,
],
@@ -568,6 +589,18 @@ class ClashMeta extends AbstractProtocol
if ($host = data_get($protocol_settings, 'network_settings.host'))
$array['ws-opts']['headers'] = ['Host' => $host];
break;
case 'xhttp':
$array['network'] = 'xhttp';
$xhttpOpts = [];
if ($path = data_get($protocol_settings, 'network_settings.path'))
$xhttpOpts['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host'))
$xhttpOpts['host'] = $host;
if ($mode = data_get($protocol_settings, 'network_settings.mode'))
$xhttpOpts['mode'] = $mode;
if (!empty($xhttpOpts))
$array['xhttp-opts'] = $xhttpOpts;
break;
default:
break;
}
@@ -602,8 +635,8 @@ class ClashMeta extends AbstractProtocol
];
break;
default: // Standard TLS
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', data_get($protocol_settings, 'allow_insecure', false));
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name', data_get($protocol_settings, 'server_name'))) {
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['sni'] = $serverName;
}
self::appendEch($array, data_get($protocol_settings, 'tls_settings.ech'));
+29 -5
View File
@@ -135,6 +135,17 @@ class General extends AbstractProtocol
$config['path'] = $path;
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
break;
case 'xhttp':
$config['net'] = 'xhttp';
$config['type'] = 'xhttp';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$config['path'] = $path;
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto'))
$config['mode'] = $mode;
if ($extra = data_get($protocol_settings, 'network_settings.extra'))
$config['extra'] = is_array($extra) && !empty($extra) ? json_encode($extra) : null;
break;
default:
break;
}
@@ -216,10 +227,13 @@ class General extends AbstractProtocol
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
break;
case 'xhttp':
$config['path'] = data_get($protocol_settings, 'network_settings.path');
if ($path = data_get($protocol_settings, 'network_settings.path'))
$config['path'] = $path;
$config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
$config['mode'] = data_get($protocol_settings, 'network_settings.mode', 'auto');
$config['extra'] = json_encode(data_get($protocol_settings, 'network_settings.extra'));
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto'))
$config['mode'] = $mode;
if ($extra = data_get($protocol_settings, 'network_settings.extra'))
$config['extra'] = is_array($extra) && !empty($extra) ? json_encode($extra) : null;
break;
}
@@ -248,8 +262,8 @@ class General extends AbstractProtocol
}
break;
default: // Standard TLS
$array['allowInsecure'] = data_get($protocol_settings, 'allow_insecure', false);
if ($serverName = data_get($protocol_settings, 'server_name')) {
$array['allowInsecure'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['peer'] = $serverName;
$array['sni'] = $serverName;
}
@@ -286,6 +300,16 @@ class General extends AbstractProtocol
$array['path'] = $path;
$array['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
break;
case 'xhttp':
$array['type'] = 'xhttp';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$array['path'] = $path;
$array['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']);
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto'))
$array['mode'] = $mode;
if ($extra = data_get($protocol_settings, 'network_settings.extra'))
$array['extra'] = is_array($extra) && !empty($extra) ? json_encode($extra) : null;
break;
default:
break;
}
+34 -2
View File
@@ -205,10 +205,10 @@ class Loon extends AbstractProtocol
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'reality_settings.allow_insecure', false) ? 'true' : 'false');
break;
default: // Standard TLS
if ($serverName = data_get($protocol_settings, 'server_name')) {
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config[] = "tls-name={$serverName}";
}
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'allow_insecure') ? 'true' : 'false');
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'tls_settings.allow_insecure', false) ? 'true' : 'false');
break;
}
@@ -225,6 +225,20 @@ class Loon extends AbstractProtocol
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$config[] = "grpc-service-name={$serviceName}";
break;
case 'h2':
$config[] = 'transport=h2';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$config[] = "path={$path}";
if ($host = data_get($protocol_settings, 'network_settings.host'))
$config[] = "host=" . (is_array($host) ? $host[0] : $host);
break;
case 'httpupgrade':
$config[] = 'transport=httpupgrade';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$config[] = "path={$path}";
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host']))
$config[] = "host={$host}";
break;
}
$config = array_filter($config);
@@ -295,6 +309,24 @@ class Loon extends AbstractProtocol
$config[] = "grpc-service-name={$serviceName}";
}
break;
case 'h2':
$config[] = "transport=h2";
if ($path = data_get($protocol_settings, 'network_settings.path')) {
$config[] = "path={$path}";
}
if ($host = data_get($protocol_settings, 'network_settings.host')) {
$config[] = "host=" . (is_array($host) ? $host[0] : $host);
}
break;
case 'httpupgrade':
$config[] = "transport=httpupgrade";
if ($path = data_get($protocol_settings, 'network_settings.path')) {
$config[] = "path={$path}";
}
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) {
$config[] = "host={$host}";
}
break;
default:
$config[] = "transport=tcp";
break;
+2 -2
View File
@@ -191,8 +191,8 @@ class QuantumultX extends AbstractProtocol
];
$tlsData = [
'allow_insecure' => data_get($protocol_settings, 'allow_insecure', false),
'server_name' => data_get($protocol_settings, 'server_name'),
'allow_insecure' => data_get($protocol_settings, 'tls_settings.allow_insecure', false),
'server_name' => data_get($protocol_settings, 'tls_settings.server_name'),
];
self::applyTransportSettings($config, $protocol_settings, true, $tlsData);
self::applyCommonSettings($config, $server);
+42 -4
View File
@@ -23,6 +23,10 @@ class Shadowrocket extends AbstractProtocol
protected $protocolRequirements = [
'shadowrocket.hysteria.protocol_settings.version' => [2 => '1993'],
'shadowrocket.anytls.base_version' => '2592',
'shadowrocket.trojan.protocol_settings.network' => [
'whitelist' => ['tcp', 'ws', 'grpc', 'h2', 'httpupgrade'],
'strict' => true,
],
];
public function handle()
@@ -137,7 +141,7 @@ class Shadowrocket extends AbstractProtocol
$config['obfsParam'] = $host;
}
break;
case 'h2':
case 'h2':
$config['obfs'] = "h2";
if ($path = data_get($protocol_settings, 'network_settings.path')) {
$config['path'] = $path;
@@ -147,6 +151,18 @@ class Shadowrocket extends AbstractProtocol
$config['peer'] = $host [0] ?? $server['host'];
}
break;
case 'xhttp':
$config['obfs'] = "xhttp";
if ($path = data_get($protocol_settings, 'network_settings.path')) {
$config['path'] = $path;
}
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) {
$config['obfsParam'] = $host;
}
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto')) {
$config['mode'] = $mode;
}
break;
}
$query = http_build_query($config, '', '&', PHP_QUERY_RFC3986);
$uri = "vmess://{$userinfo}?{$query}";
@@ -161,7 +177,6 @@ class Shadowrocket extends AbstractProtocol
$config = [
'tfo' => 1,
'remark' => $server['name'],
'alterId' => 0
];
// 判断是否开启xtls
@@ -282,8 +297,8 @@ class Shadowrocket extends AbstractProtocol
$params['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
break;
default: // Standard TLS
$params['allowInsecure'] = data_get($protocol_settings, 'allow_insecure');
if ($serverName = data_get($protocol_settings, 'server_name')) {
$params['allowInsecure'] = (int) data_get($protocol_settings, 'tls_settings.allow_insecure');
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$params['peer'] = $serverName;
}
break;
@@ -299,6 +314,29 @@ class Shadowrocket extends AbstractProtocol
$path = data_get($protocol_settings, 'network_settings.path');
$params['plugin'] = "obfs-local;obfs=websocket;obfs-host={$host};obfs-uri={$path}";
break;
case 'h2':
$params['obfs'] = 'h2';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$params['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host']))
$params['obfsParam'] = is_array($host) ? $host[0] : $host;
break;
case 'httpupgrade':
$params['obfs'] = 'httpupgrade';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$params['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host']))
$params['obfsParam'] = $host;
break;
case 'xhttp':
$params['obfs'] = 'xhttp';
if ($path = data_get($protocol_settings, 'network_settings.path'))
$params['path'] = $path;
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host']))
$params['obfsParam'] = $host;
if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto'))
$params['mode'] = $mode;
break;
}
$query = http_build_query($params);
$addr = Helper::wrapIPv6($server['host']);
+11 -2
View File
@@ -40,16 +40,25 @@ class SingBox extends AbstractProtocol
],
'protocol_settings.tls_settings.ech.enabled' => [
1 => '1.5.0'
],
'protocol_settings.network' => [
'xhttp' => '9999.0.0'
]
],
'vmess' => [
'protocol_settings.tls_settings.ech.enabled' => [
1 => '1.5.0'
],
'protocol_settings.network' => [
'xhttp' => '9999.0.0'
]
],
'trojan' => [
'protocol_settings.tls_settings.ech.enabled' => [
1 => '1.5.0'
],
'protocol_settings.network' => [
'xhttp' => '9999.0.0'
]
],
'hysteria' => [
@@ -537,9 +546,9 @@ class SingBox extends AbstractProtocol
];
break;
default: // Standard TLS
$tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', data_get($protocol_settings, 'allow_insecure', false));
$tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
$this->appendEch($tlsConfig, data_get($protocol_settings, 'tls_settings.ech'));
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name', data_get($protocol_settings, 'server_name'))) {
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$tlsConfig['server_name'] = $serverName;
}
break;
+2 -2
View File
@@ -397,10 +397,10 @@ class Stash extends AbstractProtocol
];
break;
default: // Standard TLS
if ($serverName = data_get($protocol_settings, 'server_name')) {
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['sni'] = $serverName;
}
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure', false);
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
break;
}
+3 -3
View File
@@ -186,12 +186,12 @@ class Surfboard extends AbstractProtocol
"{$server['host']}",
"{$server['port']}",
"password={$password}",
data_get($protocol_settings, 'server_name') ? "sni=" . data_get($protocol_settings, 'server_name') : "",
data_get($protocol_settings, 'tls_settings.server_name') ? "sni=" . data_get($protocol_settings, 'tls_settings.server_name') : "",
'tfo=true',
'udp-relay=true'
];
if (data_get($protocol_settings, 'allow_insecure')) {
array_push($config, !!data_get($protocol_settings, 'allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false');
if (data_get($protocol_settings, 'tls_settings.allow_insecure', false)) {
$config[] = 'skip-cert-verify=true';
}
$config = array_filter($config);
$uri = implode(',', $config);
+3 -3
View File
@@ -195,12 +195,12 @@ class Surge extends AbstractProtocol
"{$server['host']}",
"{$server['port']}",
"password={$password}",
data_get($protocol_settings, 'server_name') ? "sni=" . data_get($protocol_settings, 'server_name') : "",
data_get($protocol_settings, 'tls_settings.server_name') ? "sni=" . data_get($protocol_settings, 'tls_settings.server_name') : "",
'tfo=true',
'udp-relay=true'
];
if (!empty($protocol_settings['allow_insecure'])) {
array_push($config, !!data_get($protocol_settings, 'allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false');
if (data_get($protocol_settings, 'tls_settings.allow_insecure', false)) {
$config[] = 'skip-cert-verify=true';
}
$config = array_filter($config);
$uri = implode(',', $config);
+5 -2
View File
@@ -22,8 +22,11 @@ class PluginServiceProvider extends ServiceProvider
public function boot(): void
{
if (!file_exists(base_path('plugins'))) {
mkdir(base_path('plugins'), 0755, true);
foreach (['plugins', 'plugins-core'] as $dir) {
$path = base_path($dir);
if (!file_exists($path)) {
mkdir($path, 0755, true);
}
}
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ class MailLinkService
'subject' => __('Login to :name', [
'name' => admin_setting('app_name', 'Notification Service')
]),
'template_name' => 'login',
'template_name' => 'mailLogin',
'template_value' => [
'name' => admin_setting('app_name', 'Notification Service'),
'link' => $link,
+20
View File
@@ -32,6 +32,9 @@ class DeviceStateService
$this->removeNodeDevices($nodeId, $userId);
// Normalize: strip port suffix and deduplicate
$ips = array_values(array_unique(array_map([self::class, 'normalizeIP'], $ips)));
if (!empty($ips)) {
$fields = [];
foreach ($ips as $ip) {
@@ -98,6 +101,7 @@ class DeviceStateService
Redis::hdel($key, $field);
}
}
$this->notifyUpdate($userId);
}
return array_keys($oldDevices);
@@ -166,6 +170,22 @@ class DeviceStateService
return $result;
}
/**
* Strip port from IP address: "1.2.3.4:12345" "1.2.3.4", "[::1]:443" "::1"
*/
private static function normalizeIP(string $ip): string
{
// [IPv6]:port
if (preg_match('/^\[(.+)\]:\d+$/', $ip, $m)) {
return $m[1];
}
// IPv4:port
if (preg_match('/^(\d+\.\d+\.\d+\.\d+):\d+$/', $ip, $m)) {
return $m[1];
}
return $ip;
}
/**
* notify update (throttle control)
*/
+1
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Jobs\SendEmailJob;
use App\Models\MailLog;
use App\Models\MailTemplate;
use App\Models\User;
use App\Utils\CacheKey;
use Illuminate\Support\Facades\Cache;
+60 -13
View File
@@ -14,6 +14,7 @@ use Illuminate\Support\Str;
class PluginManager
{
protected string $pluginPath;
protected string $corePluginPath;
protected array $loadedPlugins = [];
protected bool $pluginsInitialized = false;
protected array $configTypesCache = [];
@@ -21,6 +22,7 @@ class PluginManager
public function __construct()
{
$this->pluginPath = base_path('plugins');
$this->corePluginPath = base_path('plugins-core');
}
/**
@@ -31,14 +33,42 @@ class PluginManager
return 'Plugin\\' . Str::studly($pluginCode);
}
/**
* 获取插件的基础路径
*/
public function resolvePluginPath(string $pluginCode): ?string
{
$dirName = Str::studly($pluginCode);
$corePath = $this->corePluginPath . '/' . $dirName;
if (File::isDirectory($corePath)) {
return $corePath;
}
$userPath = $this->pluginPath . '/' . $dirName;
if (File::isDirectory($userPath)) {
return $userPath;
}
return null;
}
public function getPluginPath(string $pluginCode): string
{
return $this->resolvePluginPath($pluginCode)
?? $this->pluginPath . '/' . Str::studly($pluginCode);
}
public function getUserPluginPath(string $pluginCode): string
{
return $this->pluginPath . '/' . Str::studly($pluginCode);
}
public function isCorePlugin(string $pluginCode): bool
{
$dirName = Str::studly($pluginCode);
return File::isDirectory($this->corePluginPath . '/' . $dirName);
}
public function getPluginPaths(): array
{
return [$this->corePluginPath, $this->pluginPath];
}
/**
* 加载插件类
*/
@@ -399,17 +429,19 @@ class PluginManager
*/
public function delete(string $pluginCode): bool
{
// 先卸载插件
if (Plugin::where('code', $pluginCode)->exists()) {
$this->uninstall($pluginCode);
}
$pluginPath = $this->getPluginPath($pluginCode);
if ($this->isCorePlugin($pluginCode)) {
throw new \Exception('核心插件不允许删除');
}
$pluginPath = $this->getUserPluginPath($pluginCode);
if (!File::exists($pluginPath)) {
throw new \Exception('插件不存在');
}
// 删除插件目录
File::deleteDirectory($pluginPath);
return true;
@@ -527,7 +559,7 @@ class PluginManager
throw new \Exception('插件配置文件格式错误');
}
$targetPath = $this->pluginPath . '/' . Str::studly($config['code']);
$targetPath = $this->getUserPluginPath($config['code']);
if (File::exists($targetPath)) {
$installedConfigPath = $targetPath . '/config.json';
if (!File::exists($installedConfigPath)) {
@@ -678,12 +710,27 @@ class PluginManager
*/
public static function installDefaultPlugins(): void
{
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
if (!Plugin::where('code', $pluginCode)->exists()) {
$pluginManager = app(self::class);
$pluginManager->install($pluginCode);
$pluginManager->enable($pluginCode);
Log::info("Installed and enabled default plugin: {$pluginCode}");
$pluginManager = app(self::class);
$coreDir = base_path('plugins-core');
if (!File::isDirectory($coreDir)) {
return;
}
foreach (File::directories($coreDir) as $directory) {
$configFile = $directory . '/config.json';
if (!File::exists($configFile)) {
continue;
}
$config = json_decode(File::get($configFile), true);
$code = $config['code'] ?? null;
if (!$code) {
continue;
}
if (!Plugin::where('code', $code)->exists()) {
$pluginManager->install($code);
$pluginManager->enable($code);
Log::info("Installed and enabled core plugin: {$code}");
}
}
}
+2 -5
View File
@@ -284,15 +284,12 @@ class ServerService
'trojan' => [
...$baseConfig,
'host' => $host,
'server_name' => data_get($protocolSettings, 'tls_settings.server_name') ?? $protocolSettings['server_name'],
'server_name' => data_get($protocolSettings, 'tls_settings.server_name'),
'multiplex' => data_get($protocolSettings, 'multiplex'),
'tls' => (int) $protocolSettings['tls'],
'tls_settings' => match ((int) $protocolSettings['tls']) {
2 => $protocolSettings['reality_settings'],
default => array_merge($protocolSettings['tls_settings'] ?? [], [
'server_name' => data_get($protocolSettings, 'tls_settings.server_name') ?? $protocolSettings['server_name'],
'allow_insecure' => data_get($protocolSettings, 'tls_settings.allow_insecure', $protocolSettings['allow_insecure']),
]),
default => $protocolSettings['tls_settings'],
},
],
'vless' => [
+13 -29
View File
@@ -22,11 +22,11 @@ class TicketService
'ticket_id' => $ticket->id,
'message' => $message
]);
if ($userId !== $ticket->user_id) {
$ticket->reply_status = Ticket::STATUS_OPENING;
} else {
$ticket->reply_status = Ticket::STATUS_CLOSED;
}
$isAdmin = $userId !== $ticket->user_id;
$ticket->reply_status = $isAdmin
? Ticket::REPLY_STATUS_REPLIED
: Ticket::REPLY_STATUS_WAITING;
$ticket->last_reply_user_id = $userId;
if (!$ticketMessage || !$ticket->save()) {
throw new \Exception();
}
@@ -40,33 +40,15 @@ class TicketService
public function replyByAdmin($ticketId, $message, $userId): void
{
$ticket = Ticket::where('id', $ticketId)
->first();
$ticket = Ticket::where('id', $ticketId)->first();
if (!$ticket) {
throw new ApiException('工单不存在');
}
$ticket->status = Ticket::STATUS_OPENING;
try {
DB::beginTransaction();
$ticketMessage = TicketMessage::create([
'user_id' => $userId,
'ticket_id' => $ticket->id,
'message' => $message
]);
if ($userId !== $ticket->user_id) {
$ticket->reply_status = Ticket::STATUS_OPENING;
} else {
$ticket->reply_status = Ticket::STATUS_CLOSED;
}
if (!$ticketMessage || !$ticket->save()) {
throw new ApiException('工单回复失败');
}
DB::commit();
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
} catch (\Exception $e) {
DB::rollBack();
throw $e;
$ticketMessage = $this->reply($ticket, $message, $userId);
if (!$ticketMessage) {
throw new ApiException('工单回复失败');
}
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
$this->sendEmailNotify($ticket, $ticketMessage);
}
@@ -81,7 +63,9 @@ class TicketService
$ticket = Ticket::create([
'user_id' => $userId,
'subject' => $subject,
'level' => $level
'level' => $level,
'reply_status' => Ticket::REPLY_STATUS_WAITING,
'last_reply_user_id' => $userId,
]);
if (!$ticket) {
throw new ApiException('工单创建失败');
+4
View File
@@ -151,6 +151,10 @@ abstract class AbstractProtocol
if (is_array($filterRule) && isset($filterRule['whitelist'])) {
$allowedValues = $filterRule['whitelist'];
$strict = $filterRule['strict'] ?? false;
// Normalize flat array ['tcp', 'ws'] to ['tcp' => '0.0.0', 'ws' => '0.0.0']
if (!empty($allowedValues) && is_int(array_key_first($allowedValues))) {
$allowedValues = array_fill_keys($allowedValues, '0.0.0');
}
if ($strict) {
if ($actualValue === null) {
return false;
+9
View File
@@ -19,6 +19,10 @@ class NodeWorker
private const AUTH_TIMEOUT = 10;
private const PING_INTERVAL = 55;
public const HEARTBEAT_CACHE_KEY = 'ws_server:heartbeat';
private const HEARTBEAT_INTERVAL = 10;
private const HEARTBEAT_TTL = 30;
private Worker $worker;
private array $handlers = [
@@ -70,6 +74,11 @@ class NodeWorker
private function setupTimers(): void
{
Cache::put(self::HEARTBEAT_CACHE_KEY, time(), self::HEARTBEAT_TTL);
Timer::add(self::HEARTBEAT_INTERVAL, function () {
Cache::put(self::HEARTBEAT_CACHE_KEY, time(), self::HEARTBEAT_TTL);
});
Timer::add(self::PING_INTERVAL, function () {
$seen = [];