Compare commits

...

76 Commits

Author SHA1 Message Date
Xboard e61e3b6563 Revert "refactor: core plugins to plugins-core"
This reverts commit c0b6ee1763.
2026-04-19 08:06:51 +00:00
xboard c36054b970 fix: restore stripped protocol_settings fields for tuic/mieru/vmess 2026-04-19 00:45:47 +08:00
xboard c0b6ee1763 refactor: core plugins to plugins-core 2026-04-18 23:31:59 +08:00
xboard fe62542b7c fix: unify Trojan server_name/allow_insecure to tls_settings across all protocols 2026-04-18 21:00:21 +08:00
xboard bdd7820a69 feat(admin): batch node-machine binding & frontend update 2026-04-18 19:38:31 +08:00
xboard 1603359120 fix: commit composer.lock for reproducible Docker builds 2026-04-18 16:56:41 +08:00
xboard 360684245e fix: ticket reply_status semantics, N+1 query, and admin reply auto-reopen 2026-04-18 16:40:21 +08:00
xboard da8b5018ea fix: Shadowrocket Trojan whitelist bug and xhttp support 2026-04-18 16:07:22 +08:00
xboard 9ba946621e feat: email template management with DB override, modern mail redesign 2026-04-18 15:41:23 +08:00
xboard e689699f44 fix: batchUpdate use model instance to trigger observer 2026-04-18 04:26:25 +08:00
xboard 521d4e3ac5 fix: dedup device IPs, reset stale online_count on disconnect and scheduled cleanup (#886) 2026-04-18 02:57:55 +08:00
xboard 1708b6564b feat: add xhttp subscriptions, network monitoring, chart legend toggle and ticket sender labels 2026-04-18 02:02:06 +08:00
xboard d9833fab47 fix(plugin): improve plugin install and uninstall migration handling 2026-04-17 23:11:03 +08:00
xboard f84afc7903 fix: support both GET and POST for handshake endpoint (backward compatibility) 2026-04-17 20:59:48 +08:00
Xboard fea7d97aa3 Update installer URL to use 'dev' branch 2026-04-17 12:54:22 +08:00
xboard a74cc2f19d feat: show install command on machine creation 2026-04-17 06:47:05 +08:00
xboard e297b5fe9f feat: machine mode, ECH subscriptions, batch ops & security hardening 2026-04-17 03:02:53 +08:00
yootus edbd8de356 QuantumultX下发Anytls节点 (#880)
QuantumultX最新版本支持Anytls了,做适配
2026-04-16 19:31:24 +08:00
xboard 13756956a6 fix: reset traffic stats when copying server nodes 2026-04-11 20:24:43 +08:00
Valentin Lobstein 121511523f Fix: CVE-2026-39912 - Magic link token leak in loginWithMailLink (#873)
The loginWithMailLink endpoint returns the magic login link in the
HTTP response body, allowing unauthenticated account takeover.

The fix returns true instead of the link. The email delivery is
the authentication factor.

Bug inherited from V2Board commit bdb10bed (2022-06-27).
2026-04-10 02:44:20 +08:00
xboard 1fe6531924 fix(update): avoid duplicate safe.directory entries for repo and admin submodule 2026-04-09 20:31:19 +08:00
xboard 38ea7d0067 docs: add donation section 2026-04-09 00:21:28 +08:00
xboard 58ef46f754 fix: stop sending VLESS decryption when encryption is disabled 2026-04-08 11:05:55 +08:00
yootus ec49ba3fd1 Loon和Surfboard适配anytls (#854)
* Loon适配anytls

* Surfboard适配anytls

Surfboard适配anytls
2026-04-02 15:47:41 +08:00
NFamou b7c8b31a91 Merge pull request #856 from NFamou/master
支持Surfboard下发SS2022
2026-04-02 15:46:55 +08:00
xboard f3fd40008b updata admin asset 2026-04-02 05:51:16 +08:00
Xboard 94fc5f6942 Merge pull request #841 from cedar2025/revert-755-feat/server-id-stat-user
Revert "feat: Track user traffic per node (server_id)"
2026-03-30 18:18:35 +08:00
Xboard c5a8c836c0 Revert "feat: Track user traffic per node (server_id)" 2026-03-30 18:17:27 +08:00
xboard 048530a893 Remove duplicate doc files 2026-03-30 18:04:41 +08:00
xboard 7ed5fc8fd3 fix: remove 2026_03_28_050000_lowercase_existing_emails.php 2026-03-30 17:59:39 +08:00
xboard 5f1afe4bdc feat: add Vless Encryption support 2026-03-30 17:03:37 +08:00
Xboard 0cd20d12dd Merge pull request #755 from socksprox/feat/server-id-stat-user
feat: Track user traffic per node (server_id)
2026-03-30 13:55:11 +08:00
Xboard b4a94d1605 Merge pull request #689 from socksprox/fix-user-generation-multiple-prefix
Fix user generation with email_prefix to support multiple users
2026-03-30 13:32:46 +08:00
Xboard 7879a9ef85 Merge pull request #786 from lithromantic/master
Add sha256salt hashing option in password verification
2026-03-30 13:05:39 +08:00
xboard d6a3614d98 update 2026_03_28_161536_add_traffic_fields_to_servers.php 2026-03-30 02:58:09 +08:00
xboard a58d66d72e feat: node traffic limit & batch operations
- Traffic monitoring with transfer_enable limit
- Batch delete nodes
- Reset traffic (single/batch)
2026-03-30 02:50:56 +08:00
xboard daf3055b42 fix: escape Telegram Markdown special characters 2026-03-30 01:46:56 +08:00
xboard 3744ebcd5a Revert "fix: escape Telegram Markdown special characters (fix #450)"
This reverts commit 23294c1f93.
2026-03-29 17:48:49 +08:00
lithromantic 6cac241144 Merge branch 'cedar2025:master' into master 2026-03-29 00:00:34 +01:00
Xboard 76a800ddbb Merge pull request #832 from Dlphine/fix/raw-array-access-data-get
fix: replace raw array access with data_get() to prevent Undefined array key
2026-03-28 17:38:44 +08:00
xboard bbc96a18bc fix: use getHost() for proper host comparison in safe mode 2026-03-28 15:52:25 +08:00
xboard 23294c1f93 fix: escape Telegram Markdown special characters (fix #450) 2026-03-28 09:10:54 +08:00
xboard 130f7c82a8 feat: revoke other sessions when changing password (fix #414) 2026-03-28 08:31:24 +08:00
xboard 0ab67c7a9b fix: add ru-RU.json 2026-03-28 07:44:43 +08:00
xboard 5512841ba2 fix: iOS Safari autofill not filling email field (Fixes #330) 2026-03-28 07:41:57 +08:00
xboard 7fbd1bb92d feat: implement email case-insensitive queries (fix #318) 2026-03-28 07:09:21 +08:00
Dlphine 5dd4cd4bc9 fix: replace raw array access with data_get() to prevent Undefined array key
- Migrate $protocol_settings['key'] to data_get($protocol_settings, 'key') across General, SingBox, Shadowrocket, Surfboard, QuantumultX
- Prevents PHP 8 Undefined array key fatal errors when optional protocol_settings fields are missing
- Same class of bug that caused #735
2026-03-27 13:51:28 +08:00
xboard a6c37bb112 feat: add Russian language support and remove v2board theme 2026-03-26 23:35:30 +08:00
xboard 3c3639613e fix: use ServerService::getServer() for node lookup in WebSocket 2026-03-26 03:51:58 +08:00
xboard 74b5ef0b27 fix: resolve device sync issues and refactor WebSocket server 2026-03-26 03:33:01 +08:00
xboard 420521d90a refactor: restructure device limit system 2026-03-25 17:50:16 +08:00
xboard 73a37a07dd feat: ws notify nodes when user traffic is exhausted 2026-03-25 01:44:55 +08:00
xboard 7dacb69275 feat: Trojan Reality support and protocol distribution optimizations 2026-03-23 14:56:41 +08:00
xboard a712be7cd4 update admin assets submodule 2026-03-23 11:00:31 +08:00
xboard 08d68cbcae fix: intval u/d to avoid bigint overflow (#821) 2026-03-22 19:13:07 +08:00
Xboard b779bd4fd5 Merge pull request #789 from socksprox/feat/or-filter-logic
feat: Add OR logic support to user fetch API filters
2026-03-21 07:49:03 +08:00
xboard 64e6d8148e feat: Add admin bulk-mail placeholder variables and template rendering 2026-03-19 05:02:16 +08:00
xboard 47983dec40 fix(runtime): force app_url/force_https per-request via middlewar 2026-03-19 04:22:17 +08:00
xboard 139b34ca19 fix(compose): use named volume for redis socket 2026-03-17 22:50:29 +08:00
xboard 9ef61e317c fix(admin): fix giftcard form validation and template creation issues 2026-03-17 18:35:21 +08:00
xboard fe0f1760bd fix(admin): fix node manage tooltip error 2026-03-17 14:13:48 +08:00
Xboard 4a2fbd4d3d Add port mapping for 1panel service 2026-03-17 13:16:43 +08:00
xboard ee55d7fa72 fix: fix brutal-opts configure for clashMeta 2026-03-17 12:26:10 +08:00
xboard e06cd279cf fix(admin): resolve translation key issues 2026-03-17 11:44:17 +08:00
xboard 6eecbb0e4b fix:Fix DynamicForm default value sync, payment unit conversion, advanced config tabs, AnyTLS translation, and UI overlap issues. Fixed #807 2026-03-17 03:21:27 +08:00
Xboard 2ff561e185 Add ws-server service configuration to 1panel.md 2026-03-17 02:36:21 +08:00
xboard dd96e37116 fix(admin): fix order assign 2026-03-17 00:54:26 +08:00
socksprox 3b3fc618d6 Make querying users better with "or" statements 2026-01-28 01:27:21 +01:00
lithromantic f6abc362fd Add sha256salt hashing option in password verification 2026-01-18 00:04:00 +01:00
socksprox c327fecb49 do not return strings, but int 2025-11-29 17:05:07 +01:00
socksprox 0446f88e9e again: update api combining times 2025-11-29 17:05:07 +01:00
socksprox a01151130e Revert "Combine data with node_id in api output, so its all still "one day", and fits vanilla xboard behaviour"
This reverts commit de39230cbe111bbf793f11bcf5046ef717c67f87.

The api change caused issues
2025-11-29 17:05:07 +01:00
socksprox 9ca8da045c Combine data with node_id in api output, so its all still "one day", and fits vanilla xboard behaviour 2025-11-29 14:07:10 +01:00
socksprox 1ebf86b510 fix: do not merge traffic from different nodes 2025-11-29 13:47:21 +01:00
socksprox 9e35d16fa6 User traffic can now be viewed by node 2025-11-29 13:47:15 +01:00
socksprox 051813d39d Make that user batch generation works again 2025-09-15 15:43:43 +02:00
150 changed files with 17080 additions and 16819 deletions
-2
View File
@@ -11,13 +11,11 @@
.env.backup
.phpunit.result.cache
.idea
.lock
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
composer.phar
composer.lock
yarn.lock
docker-compose.yml
.DS_Store
+6
View File
@@ -73,6 +73,12 @@ docker compose up -d
This project is for learning and communication purposes only. Users are responsible for any consequences of using this project.
## ❤️ Support The Project
If this project has helped you, donations are appreciated. They help support ongoing maintenance and would make me very happy.
TRC20: `TLypStEWsVrj6Wz9mCxbXffqgt5yz3Y4XB`
## 🌟 Maintenance Notice
This project is currently under light maintenance. We will:
+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,63 @@
<?php
namespace App\Console\Commands;
use App\Models\Server;
use App\Models\User;
use App\Services\NodeSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class CheckTrafficExceeded extends Command
{
protected $signature = 'check:traffic-exceeded';
protected $description = '检查流量超标用户并通知节点';
public function handle()
{
$count = Redis::scard('traffic:pending_check');
if ($count <= 0) {
return;
}
$pendingUserIds = array_map('intval', Redis::spop('traffic:pending_check', $count));
$exceededUsers = User::toBase()
->whereIn('id', $pendingUserIds)
->whereRaw('u + d >= transfer_enable')
->where('transfer_enable', '>', 0)
->where('banned', 0)
->select(['id', 'group_id'])
->get();
if ($exceededUsers->isEmpty()) {
return;
}
$groupedUsers = $exceededUsers->groupBy('group_id');
$notifiedCount = 0;
foreach ($groupedUsers as $groupId => $users) {
if (!$groupId) {
continue;
}
$userIdsInGroup = $users->pluck('id')->toArray();
$servers = Server::whereJsonContains('group_ids', (string) $groupId)->get();
foreach ($servers as $server) {
if (!NodeSyncService::isNodeOnline($server->id)) {
continue;
}
NodeSyncService::push($server->id, 'sync.user.delta', [
'action' => 'remove',
'users' => array_map(fn($id) => ['id' => $id], $userIdsInGroup),
]);
$notifiedCount++;
}
}
$this->info("Checked " . count($pendingUserIds) . " users, notified {$notifiedCount} nodes for " . $exceededUsers->count() . " exceeded users.");
}
}
@@ -1,52 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\User;
use Illuminate\Support\Facades\Log;
class CleanupExpiredOnlineStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cleanup:expired-online-status';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reset online_count to 0 for users stale for 5+ minutes';
/**
* Execute the console command.
*/
public function handle()
{
try {
$affected = 0;
User::query()
->where('online_count', '>', 0)
->where('last_online_at', '<', now()->subMinutes(5))
->chunkById(1000, function ($users) use (&$affected) {
if ($users->isEmpty()) {
return;
}
$count = User::whereIn('id', $users->pluck('id'))
->update(['online_count' => 0]);
$affected += $count;
}, 'id');
$this->info("Expired online status cleaned. Affected: {$affected}");
return self::SUCCESS;
} catch (\Throwable $e) {
Log::error('CleanupExpiredOnlineStatus failed', ['error' => $e->getMessage()]);
$this->error('Cleanup failed: ' . $e->getMessage());
return self::FAILURE;
}
}
}
@@ -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.");
}
}
}
+3 -248
View File
@@ -2,17 +2,8 @@
namespace App\Console\Commands;
use App\Models\Server;
use App\Services\NodeSyncService;
use App\Services\NodeRegistry;
use App\Services\ServerService;
use App\WebSocket\NodeWorker;
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
{
@@ -24,18 +15,11 @@ class NodeWebSocketServer extends Command
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';
@@ -44,236 +28,7 @@ class NodeWebSocketServer extends Command
$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),
]);
$worker = new NodeWorker($host, $port);
$worker->run();
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ class ResetPassword extends Command
public function handle()
{
$password = $this->argument('password') ;
$user = User::where('email', $this->argument('email'))->first();
$user = User::byEmail($this->argument('email'))->first();
if (!$user) abort(500, '邮箱不存在');
$password = $password ?? Helper::guid(false);
$user->password = password_hash($password, PASSWORD_DEFAULT);
+3 -3
View File
@@ -7,7 +7,6 @@ use App\Utils\CacheKey;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Illuminate\Support\Facades\Cache;
use App\Services\UserOnlineService;
class Kernel extends ConsoleKernel
{
@@ -35,6 +34,7 @@ class Kernel extends ConsoleKernel
$schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:ticket')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:traffic-exceeded')->everyMinute()->onOneServer()->withoutOverlapping(10)->runInBackground();
// reset
$schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10);
$schedule->command('reset:log')->daily()->onOneServer();
@@ -42,12 +42,12 @@ 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();
// }
$schedule->command('cleanup:expired-online-status')->everyMinute()->onOneServer()->withoutOverlapping(4);
app(PluginManager::class)->registerPluginSchedules($schedule);
}
-1
View File
@@ -1,6 +1,5 @@
<?php
use App\Support\Setting;
use Illuminate\Support\Facades\App;
if (!function_exists('admin_setting')) {
/**
@@ -80,7 +80,8 @@ class ClientController extends Controller
'user' => $user,
'servers' => $serversFiltered,
'clientName' => $clientInfo['name'] ?? null,
'clientVersion' => $clientInfo['version'] ?? null
'clientVersion' => $clientInfo['version'] ?? null,
'userAgent' => $clientInfo['flag'] ?? null
]);
return $protocolInstance->handle();
@@ -29,7 +29,7 @@ class CommController extends Controller
// 检查白名单后缀限制
if ((int) admin_setting('email_whitelist_enable', 0)) {
$isRegisteredEmail = User::where('email', $email)->exists();
$isRegisteredEmail = User::byEmail($email)->exists();
if (!$isRegisteredEmail) {
$allowedSuffixes = Helper::getEmailSuffix();
$emailSuffix = substr(strrchr($email, '@'), 1);
@@ -3,89 +3,55 @@
namespace App\Http\Controllers\V1\Server;
use App\Http\Controllers\Controller;
use App\Jobs\UserAliveSyncJob;
use App\Services\NodeSyncService;
use App\Services\DeviceStateService;
use App\Services\ServerService;
use App\Services\UserService;
use App\Utils\CacheKey;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use App\Services\UserOnlineService;
use Illuminate\Http\JsonResponse;
class UniProxyController extends Controller
{
public function __construct(
private readonly UserOnlineService $userOnlineService
private readonly DeviceStateService $deviceStateService
) {
}
/**
* 获取当前请求的节点信息
*/
private function getNodeInfo(Request $request)
{
return $request->attributes->get('node_info');
}
// 后端获取用户
public function user(Request $request)
{
ini_set('memory_limit', -1);
$node = $this->getNodeInfo($request);
$nodeType = $node->type;
$nodeId = $node->id;
Cache::put(CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_CHECK_AT', $nodeId), time(), 3600);
$users = ServerService::getAvailableUsers($node);
$response['users'] = $users;
ServerService::touchNode($node);
$response['users'] = ServerService::getAvailableUsers($node);
$eTag = sha1(json_encode($response));
if (strpos($request->header('If-None-Match', ''), $eTag) !== false) {
if (str_contains($request->header('If-None-Match', ''), $eTag)) {
return response(null, 304);
}
return response($response)->header('ETag', "\"{$eTag}\"");
}
// 后端提交数据
public function push(Request $request)
{
$res = json_decode(request()->getContent(), true);
if (!is_array($res)) {
return $this->fail([422, 'Invalid data format']);
}
$data = array_filter($res, function ($item) {
return is_array($item)
&& count($item) === 2
&& is_numeric($item[0])
&& is_numeric($item[1]);
});
if (empty($data)) {
return $this->success(true);
}
$node = $this->getNodeInfo($request);
$nodeType = $node->type;
$nodeId = $node->id;
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
);
ServerService::processTraffic($node, $res);
$userService = new UserService();
$userService->trafficFetch($node, $nodeType, $data);
return $this->success(true);
}
// 后端获取配置
public function config(Request $request)
{
$node = $this->getNodeInfo($request);
@@ -97,37 +63,36 @@ class UniProxyController extends Controller
];
$eTag = sha1(json_encode($response));
if (strpos($request->header('If-None-Match', ''), $eTag) !== false) {
if (str_contains($request->header('If-None-Match', ''), $eTag)) {
return response(null, 304);
}
return response($response)->header('ETag', "\"{$eTag}\"");
}
// 获取在线用户数据(wyx2685
public function alivelist(Request $request): JsonResponse
{
$node = $this->getNodeInfo($request);
$deviceLimitUsers = ServerService::getAvailableUsers($node)
->where('device_limit', '>', 0);
$alive = $this->userOnlineService->getAliveList($deviceLimitUsers);
$alive = $this->deviceStateService->getAliveList(collect($deviceLimitUsers));
return response()->json(['alive' => (object) $alive]);
}
// 后端提交在线数据
public function alive(Request $request): JsonResponse
{
$node = $this->getNodeInfo($request);
$data = json_decode(request()->getContent(), true);
if ($data === null) {
return response()->json([
'error' => 'Invalid online data'
], 400);
return response()->json(['error' => 'Invalid online data'], 400);
}
UserAliveSyncJob::dispatch($data, $node->type, $node->id);
ServerService::processAlive($node->id, $data);
return response()->json(['data' => true]);
}
// 提交节点负载状态
public function status(Request $request): JsonResponse
{
$node = $this->getNodeInfo($request);
@@ -142,32 +107,8 @@ class UniProxyController extends Controller
'disk.used' => 'required|integer|min:0',
]);
$nodeType = $node->type;
$nodeId = $node->id;
ServerService::processStatus($node, $data);
$statusData = [
'cpu' => (float) $data['cpu'],
'mem' => [
'total' => (int) $data['mem']['total'],
'used' => (int) $data['mem']['used'],
],
'swap' => [
'total' => (int) $data['swap']['total'],
'used' => (int) $data['swap']['used'],
],
'disk' => [
'total' => (int) $data['disk']['total'],
'used' => (int) $data['disk']['used'],
],
'updated_at' => now()->timestamp,
];
$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);
return response()->json(['data' => true, "code" => 0, "message" => "success"]);
return response()->json(['data' => true, 'code' => 0, 'message' => 'success']);
}
}
@@ -74,6 +74,14 @@ class UserController extends Controller
if (!$user->save()) {
return $this->fail([400, __('Save failed')]);
}
$currentToken = $user->currentAccessToken();
if ($currentToken) {
$user->tokens()->where('id', '!=', $currentToken->id)->delete();
} else {
$user->tokens()->delete();
}
return $this->success(true);
}
@@ -147,7 +147,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}}'),
};
}
}
@@ -199,7 +199,7 @@ class OrderController extends Controller
public function assign(OrderAssign $request)
{
$plan = Plan::find($request->input('plan_id'));
$user = User::where('email', $request->input('email'))->first();
$user = User::byEmail($request->input('email'))->first();
if (!$user) {
return $this->fail([400202, '该用户不存在']);
@@ -106,6 +106,8 @@ class PluginController extends Controller
'config' => $pluginConfig,
'readme' => $readmeContent,
'need_upgrade' => $needUpgrade,
'admin_menus' => $config['admin_menus'] ?? null,
'admin_crud' => $config['admin_crud'] ?? null,
];
}
}
@@ -0,0 +1,215 @@
<?php
namespace App\Http\Controllers\V2\Admin\Server;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\ServerMachine;
use App\Models\ServerMachineLoadHistory;
use App\Services\NodeSyncService;
use Illuminate\Http\Request;
class MachineController extends Controller
{
/**
* 获取机器列表(附带关联节点数)
*/
public function fetch(Request $request)
{
$machines = ServerMachine::withCount('servers')
->orderBy('id')
->get()
->map(function (ServerMachine $machine) {
return [
'id' => $machine->id,
'name' => $machine->name,
'notes' => $machine->notes,
'is_active' => $machine->is_active,
'last_seen_at' => $machine->last_seen_at,
'load_status' => $machine->load_status,
'servers_count' => $machine->servers_count,
'created_at' => $machine->created_at,
'updated_at' => $machine->updated_at,
];
});
return $this->success($machines);
}
/**
* 创建 / 更新机器
*/
public function save(Request $request)
{
$params = $request->validate([
'id' => 'nullable|integer|exists:v2_server_machine,id',
'name' => 'required|string|max:255',
'notes' => 'nullable|string',
'is_active' => 'nullable|boolean',
]);
if (!empty($params['id'])) {
$machine = ServerMachine::find($params['id']);
$update = ['name' => $params['name']];
if (array_key_exists('notes', $params)) {
$update['notes'] = $params['notes'];
}
if (array_key_exists('is_active', $params)) {
$update['is_active'] = $params['is_active'];
}
$machine->update($update);
return $this->success(true);
}
$machine = ServerMachine::create([
'name' => $params['name'],
'notes' => $params['notes'] ?? null,
'is_active' => $params['is_active'] ?? true,
'token' => ServerMachine::generateToken(),
]);
return $this->success([
'id' => $machine->id,
'token' => $machine->token,
'install_command' => $this->buildInstallCommand($request, $machine),
]);
}
/**
* 重置机器 Token
*/
public function resetToken(Request $request)
{
$params = $request->validate([
'id' => 'required|integer|exists:v2_server_machine,id',
]);
$machine = ServerMachine::find($params['id']);
$token = ServerMachine::generateToken();
$machine->update(['token' => $token]);
return $this->success(['token' => $token]);
}
/**
* 获取机器 Token(仅展示一次,用于首次配置)
*/
public function getToken(Request $request)
{
$params = $request->validate([
'id' => 'required|integer|exists:v2_server_machine,id',
]);
$machine = ServerMachine::find($params['id']);
return $this->success(['token' => $machine->token]);
}
/**
* 获取机器模式一键安装命令
*/
public function installCommand(Request $request)
{
$params = $request->validate([
'id' => 'required|integer|exists:v2_server_machine,id',
]);
$machine = ServerMachine::find($params['id']);
return $this->success([
'command' => $this->buildInstallCommand($request, $machine),
]);
}
/**
* 删除机器(自动解除关联节点)
*/
public function drop(Request $request)
{
$params = $request->validate([
'id' => 'required|integer|exists:v2_server_machine,id',
]);
$machine = ServerMachine::find($params['id']);
$machineId = $machine->id;
// Detach nodes first (sets machine_id = null), then delete and notify
Server::where('machine_id', $machineId)->update(['machine_id' => null]);
$machine->delete();
// Notify with empty node list so WS process cleans up registry
NodeSyncService::notifyMachineNodesChanged($machineId);
return $this->success(true);
}
/**
* 获取机器下的节点列表
*/
public function nodes(Request $request)
{
$params = $request->validate([
'machine_id' => 'required|integer|exists:v2_server_machine,id',
]);
$nodes = Server::where('machine_id', $params['machine_id'])
->orderBy('sort')
->get(['id', 'name', 'type', 'host', 'port', 'show', 'enabled', 'sort']);
return $this->success($nodes);
}
/**
* 获取机器负载历史
*/
public function history(Request $request)
{
$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 = $query
->orderByDesc('recorded_at')
->limit($limit)
->get([
'cpu',
'mem_total',
'mem_used',
'disk_total',
'disk_used',
'net_in_speed',
'net_out_speed',
'recorded_at',
])
->reverse()
->values();
return $this->success($history);
}
private function buildInstallCommand(Request $request, ServerMachine $machine): string
{
$panelUrl = rtrim((string) (admin_setting('app_url') ?: $request->getSchemeAndHttpHost()), '/');
$installerUrl = 'https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh';
return sprintf(
'curl -fsSL %s | sudo bash -s -- --mode machine --panel %s --token %s --machine-id %d',
$installerUrl,
escapeshellarg($panelUrl),
escapeshellarg($machine->token),
$machine->id
);
}
}
@@ -17,7 +17,7 @@ class ManageController extends Controller
public function getNodes(Request $request)
{
$servers = ServerService::getAllServers()->map(function ($item) {
$item['groups'] = ServerGroup::whereIn('id', $item['group_ids'])->get(['name', 'id']);
$item['groups'] = ServerGroup::whereIn('id', $item['group_ids'] ?? [])->get(['name', 'id']);
$item['parent'] = $item->parent;
return $item;
});
@@ -73,25 +73,36 @@ class ManageController extends Controller
Log::error($e);
return $this->fail([500, '创建失败']);
}
}
public function update(Request $request)
{
$request->validate([
$params = $request->validate([
'id' => 'required|integer',
'show' => 'integer',
'show' => 'nullable|integer',
'machine_id' => 'nullable|integer',
'enabled' => 'nullable|boolean',
]);
$server = Server::find($request->id);
if (!$server) {
return $this->fail([400202, '服务器不存在']);
}
$server->show = (int) $request->show;
if (array_key_exists('show', $params)) {
$server->show = (int) $params['show'];
}
if (array_key_exists('machine_id', $params)) {
$server->machine_id = $params['machine_id'] ?: null;
}
if (array_key_exists('enabled', $params)) {
$server->enabled = (bool) $params['enabled'];
}
if (!$server->save()) {
return $this->fail([500, '保存失败']);
}
return $this->success(true);
}
@@ -105,12 +116,153 @@ class ManageController extends Controller
$request->validate([
'id' => 'required|integer',
]);
if (Server::where('id', $request->id)->delete() === false) {
$server = Server::find($request->id);
if (!$server) {
return $this->fail([400202, '服务器不存在']);
}
if ($server->delete() === false) {
return $this->fail([500, '删除失败']);
}
return $this->success(true);
}
/**
* 批量删除节点
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function batchDelete(Request $request)
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'integer',
]);
$ids = $request->input('ids');
if (empty($ids)) {
return $this->fail([400, '请选择要删除的节点']);
}
try {
$deleted = Server::whereIn('id', $ids)->delete();
if ($deleted === false) {
return $this->fail([500, '批量删除失败']);
}
return $this->success(true);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([500, '批量删除失败']);
}
}
/**
* 重置节点流量
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function resetTraffic(Request $request)
{
$request->validate([
'id' => 'required|integer',
]);
$server = Server::find($request->id);
if (!$server) {
return $this->fail([400202, '服务器不存在']);
}
try {
$server->u = 0;
$server->d = 0;
$server->save();
Log::info("Server {$server->id} ({$server->name}) traffic reset by admin");
return $this->success(true);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([500, '重置失败']);
}
}
/**
* 批量重置节点流量
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function batchResetTraffic(Request $request)
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'integer',
]);
$ids = $request->input('ids');
if (empty($ids)) {
return $this->fail([400, '请选择要重置的节点']);
}
try {
Server::whereIn('id', $ids)->update([
'u' => 0,
'd' => 0,
]);
Log::info("Servers " . implode(',', $ids) . " traffic reset by admin");
return $this->success(true);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([500, '批量重置失败']);
}
}
/**
* 批量更新节点属性(show等)
*/
public function batchUpdate(Request $request)
{
$params = $request->validate([
'ids' => 'required|array',
'ids.*' => 'integer',
'show' => 'nullable|integer|in:0,1',
'enabled' => 'nullable|boolean',
'machine_id' => 'nullable|integer',
]);
$ids = $params['ids'];
if (empty($ids)) {
return $this->fail([400, '请选择要更新的节点']);
}
$update = [];
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 {
$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);
return $this->fail([500, '批量更新失败']);
}
}
/**
* 复制节点
@@ -120,12 +272,70 @@ class ManageController extends Controller
public function copy(Request $request)
{
$server = Server::find($request->input('id'));
$server->show = 0;
$server->code = null;
if (!$server) {
return $this->fail([400202, '服务器不存在']);
}
Server::create($server->toArray());
$copiedServer = $server->replicate();
$copiedServer->show = 0;
$copiedServer->code = null;
$copiedServer->u = 0;
$copiedServer->d = 0;
$copiedServer->save();
return $this->success(true);
}
/**
* Generate ECH (Encrypted Client Hello) key pair.
* Returns PEM-encoded ECH key (server-side) and ECH config (client-side).
*/
public function generateEchKey(Request $request)
{
$publicName = $request->input('public_name', 'ech.example.com');
if (strlen($publicName) < 1 || strlen($publicName) > 253) {
throw new ApiException('public_name must be a valid domain (1-253 bytes)');
}
// Generate X25519 key pair
$privateKey = random_bytes(32);
$publicKey = sodium_crypto_scalarmult_base($privateKey);
$configId = random_int(0, 255);
// Build ECHConfigContents (draft-ietf-tls-esni-18)
$contents = '';
$contents .= pack('C', $configId); // config_id
$contents .= pack('n', 0x0020); // kem_id: DHKEM(X25519)
$contents .= pack('n', 32) . $publicKey; // public_key (length-prefixed)
// cipher_suites: 2 suites × 4 bytes = 8 bytes
$contents .= pack('n', 8); // cipher_suites byte length
$contents .= pack('nn', 0x0001, 0x0001); // HKDF-SHA256 + AES-128-GCM
$contents .= pack('nn', 0x0001, 0x0003); // HKDF-SHA256 + ChaCha20Poly1305
$contents .= pack('C', 0); // max_name_length
$contents .= pack('C', strlen($publicName)) . $publicName;
$contents .= pack('n', 0); // extensions: empty
// ECHConfig = version(2) + length(2) + contents
$echConfig = pack('n', 0xfe0d) . pack('n', strlen($contents)) . $contents;
// ECHConfigList = total_length(2) + configs
$echConfigList = pack('n', strlen($echConfig)) . $echConfig;
// ECH Keys = private_key_len(2) + key(32) + config_len(2) + config
$echKeysPayload = pack('n', 32) . $privateKey . pack('n', strlen($echConfig)) . $echConfig;
$keyPem = "-----BEGIN ECH KEYS-----\n"
. chunk_split(base64_encode($echKeysPayload), 64, "\n")
. "-----END ECH KEYS-----";
$configPem = "-----BEGIN ECH CONFIGS-----\n"
. chunk_split(base64_encode($echConfigList), 64, "\n")
. "-----END ECH CONFIGS-----";
return $this->success([
'key' => $keyPem,
'config' => $configPem,
]);
}
}
@@ -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
]);
+240 -78
View File
@@ -15,6 +15,7 @@ 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;
@@ -35,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;
@@ -64,25 +53,28 @@ class UserController extends Controller
collect($request->input('filter'))->each(function ($filter) use ($builder) {
$field = $filter['id'];
$value = $filter['value'];
$logic = strtolower($filter['logic'] ?? 'and');
$builder->where(function ($query) use ($field, $value) {
$this->buildFilterQuery($query, $field, $value);
});
if ($logic === 'or') {
$builder->orWhere(function ($query) use ($field, $value) {
$this->buildFilterQuery($query, $field, $value);
});
} else {
$builder->where(function ($query) use ($field, $value) {
$this->buildFilterQuery($query, $field, $value);
});
}
});
}
/**
* 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)) {
@@ -127,14 +119,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;
@@ -147,19 +133,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);
@@ -173,12 +190,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();
@@ -208,7 +220,7 @@ class UserController extends Controller
return $this->fail([400202, '用户不存在']);
}
if (isset($params['email'])) {
if (User::where('email', $params['email'])->first() && $user->email !== $params['email']) {
if (User::byEmail($params['email'])->first() && $user->email !== $params['email']) {
return $this->fail([400201, '邮箱已被使用']);
}
}
@@ -228,7 +240,7 @@ class UserController extends Controller
$params['group_id'] = $plan->group_id;
}
// 处理邀请用户
if ($request->input('invite_user_email') && $inviteUser = User::where('email', $request->input('invite_user_email'))->first()) {
if ($request->input('invite_user_email') && $inviteUser = User::byEmail($request->input('invite_user_email'))->first()) {
$params['invite_user_id'] = $inviteUser->id;
} else {
$params['invite_user_id'] = null;
@@ -254,19 +266,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',
@@ -280,7 +298,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';
@@ -341,9 +363,15 @@ class UserController extends Controller
public function generate(UserGenerate $request)
{
if ($request->input('email_prefix')) {
// If generate_count is specified with email_prefix, generate multiple users with incremented emails
if ($request->input('generate_count')) {
return $this->multiGenerateWithPrefix($request);
}
// Single user generation with email_prefix
$email = $request->input('email_prefix') . '@' . $request->input('email_suffix');
if (User::where('email', $email)->exists()) {
if (User::byEmail($email)->exists()) {
return $this->fail([400201, '邮箱已存在于系统中']);
}
@@ -437,26 +465,146 @@ class UserController extends Controller
]);
}
private function multiGenerateWithPrefix(Request $request)
{
$userService = app(UserService::class);
$usersData = [];
$emailPrefix = $request->input('email_prefix');
$emailSuffix = $request->input('email_suffix');
$generateCount = $request->input('generate_count');
// Check if any of the emails with prefix already exist
for ($i = 1; $i <= $generateCount; $i++) {
$email = $emailPrefix . '_' . $i . '@' . $emailSuffix;
if (User::where('email', $email)->exists()) {
return $this->fail([400201, '邮箱 ' . $email . ' 已存在于系统中']);
}
}
// Generate user data for batch creation
for ($i = 1; $i <= $generateCount; $i++) {
$email = $emailPrefix . '_' . $i . '@' . $emailSuffix;
$usersData[] = [
'email' => $email,
'password' => $request->input('password') ?? $email,
'plan_id' => $request->input('plan_id'),
'expired_at' => $request->input('expired_at'),
];
}
try {
DB::beginTransaction();
$users = [];
foreach ($usersData as $userData) {
$user = $userService->createUser($userData);
$user->save();
$users[] = $user;
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
return $this->fail([500, '生成失败']);
}
// 判断是否导出 CSV
if ($request->input('download_csv')) {
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="users.csv"',
];
$callback = function () use ($users, $request) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['账号', '密码', '过期时间', 'UUID', '创建时间', '订阅地址']);
foreach ($users as $user) {
$user = $user->refresh();
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']);
$createDate = date('Y-m-d H:i:s', $user['created_at']);
$password = $request->input('password') ?? $user['email'];
$subscribeUrl = Helper::getSubscribeUrl($user['token']);
fputcsv($handle, [$user['email'], $password, $expireDate, $user['uuid'], $createDate, $subscribeUrl]);
}
fclose($handle);
};
return response()->streamDownload($callback, 'users.csv', $headers);
}
// 默认返回 JSON
$data = collect($users)->map(function ($user) use ($request) {
return [
'email' => $user['email'],
'password' => $request->input('password') ?? $user['email'],
'expired_at' => $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']),
'uuid' => $user['uuid'],
'created_at' => date('Y-m-d H:i:s', $user['created_at']),
'subscribe_url' => Helper::getSubscribeUrl($user['token']),
];
});
return response()->json([
'code' => 0,
'message' => '批量生成成功',
'data' => $data,
]);
}
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';
$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', 'XBoard'),
'url' => admin_setting('app_url'),
'content' => $content
];
$appName = admin_setting('app_name', 'XBoard');
$appUrl = admin_setting('app_url');
$chunkSize = 1000;
$builder->chunk($chunkSize, function ($users) use ($subject, $templateValue, &$totalProcessed) {
$builder->chunk($chunkSize, function ($users) use ($subject, $content, $appName, $appUrl) {
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',
];
dispatch(new SendEmailJob([
'email' => $user->email,
'subject' => $subject,
@@ -471,10 +619,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
@@ -483,16 +650,11 @@ class UserController extends Controller
Log::error($e);
return $this->fail([500, '处理失败']);
}
NodeSyncService::notifyUsersUpdated();
// 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,153 @@
<?php
namespace App\Http\Controllers\V2\Client;
use App\Http\Controllers\Controller;
use App\Services\ServerService;
use App\Services\UserService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml;
class AppController extends Controller
{
public function getConfig(Request $request)
{
$config = [
'app_info' => [
'app_name' => admin_setting('app_name', 'XB加速器'), // 应用名称
'app_description' => admin_setting('app_description', '专业的网络加速服务'), // 应用描述
'app_url' => admin_setting('app_url', 'https://app.example.com'), // 应用官网 URL
'logo' => admin_setting('logo', 'https://example.com/logo.png'), // 应用 Logo URL
'version' => admin_setting('app_version', '1.0.0'), // 应用版本号
],
'features' => [
'enable_register' => (bool) admin_setting('app_enable_register', true), // 是否开启注册功能
'enable_invite_system' => (bool) admin_setting('app_enable_invite_system', true), // 是否开启邀请系统
'enable_telegram_bot' => (bool) admin_setting('telegram_bot_enable', false), // 是否开启 Telegram 机器人
'enable_ticket_system' => (bool) admin_setting('app_enable_ticket_system', true), // 是否开启工单系统
'ticket_must_wait_reply' => (bool) admin_setting('ticket_must_wait_reply', 0), // 工单是否需要等待管理员回复后才可继续发消息
'enable_commission_system' => (bool) admin_setting('app_enable_commission_system', true), // 是否开启佣金系统
'enable_traffic_log' => (bool) admin_setting('app_enable_traffic_log', true), // 是否开启流量日志
'enable_knowledge_base' => (bool) admin_setting('app_enable_knowledge_base', true), // 是否开启知识库
'enable_announcements' => (bool) admin_setting('app_enable_announcements', true), // 是否开启公告系统
'enable_auto_renewal' => (bool) admin_setting('app_enable_auto_renewal', false), // 是否开启自动续费
'enable_coupon_system' => (bool) admin_setting('app_enable_coupon_system', true), // 是否开启优惠券系统
'enable_speed_test' => (bool) admin_setting('app_enable_speed_test', true), // 是否开启测速功能
'enable_server_ping' => (bool) admin_setting('app_enable_server_ping', true), // 是否开启服务器延迟检测
],
'ui_config' => [
'theme' => [
'primary_color' => admin_setting('app_primary_color', '#00C851'), // 主色调 (十六进制)
'secondary_color' => admin_setting('app_secondary_color', '#007E33'), // 辅助色 (十六进制)
'accent_color' => admin_setting('app_accent_color', '#FF6B35'), // 强调色 (十六进制)
'background_color' => admin_setting('app_background_color', '#F5F5F5'), // 背景色 (十六进制)
'text_color' => admin_setting('app_text_color', '#333333'), // 文字色 (十六进制)
],
'home_screen' => [
'show_speed_test' => (bool) admin_setting('app_show_speed_test', true), // 是否显示测速
'show_traffic_chart' => (bool) admin_setting('app_show_traffic_chart', true), // 是否显示流量图表
'show_server_ping' => (bool) admin_setting('app_show_server_ping', true), // 是否显示服务器延迟
'default_server_sort' => admin_setting('app_default_server_sort', 'ping'), // 默认服务器排序方式
'show_connection_status' => (bool) admin_setting('app_show_connection_status', true), // 是否显示连接状态
],
'server_list' => [
'show_country_flags' => (bool) admin_setting('app_show_country_flags', true), // 是否显示国家旗帜
'show_ping_values' => (bool) admin_setting('app_show_ping_values', true), // 是否显示延迟值
'show_traffic_usage' => (bool) admin_setting('app_show_traffic_usage', true), // 是否显示流量使用
'group_by_country' => (bool) admin_setting('app_group_by_country', false), // 是否按国家分组
'show_server_status' => (bool) admin_setting('app_show_server_status', true), // 是否显示服务器状态
],
],
'business_rules' => [
'min_password_length' => (int) admin_setting('app_min_password_length', 8), // 最小密码长度
'max_login_attempts' => (int) admin_setting('app_max_login_attempts', 5), // 最大登录尝试次数
'session_timeout_minutes' => (int) admin_setting('app_session_timeout_minutes', 30), // 会话超时时间(分钟)
'auto_disconnect_after_minutes' => (int) admin_setting('app_auto_disconnect_after_minutes', 60), // 自动断开连接时间(分钟)
'max_concurrent_connections' => (int) admin_setting('app_max_concurrent_connections', 3), // 最大并发连接数
'traffic_warning_threshold' => (float) admin_setting('app_traffic_warning_threshold', 0.8), // 流量警告阈值(0-1)
'subscription_reminder_days' => admin_setting('app_subscription_reminder_days', [7, 3, 1]), // 订阅到期提醒天数
'connection_timeout_seconds' => (int) admin_setting('app_connection_timeout_seconds', 10), // 连接超时时间(秒)
'health_check_interval_seconds' => (int) admin_setting('app_health_check_interval_seconds', 30), // 健康检查间隔(秒)
],
'server_config' => [
'default_kernel' => admin_setting('app_default_kernel', 'clash'), // 默认内核 (clash/singbox)
'auto_select_fastest' => (bool) admin_setting('app_auto_select_fastest', true), // 是否自动选择最快服务器
'fallback_servers' => admin_setting('app_fallback_servers', ['server1', 'server2']), // 备用服务器列表
'enable_auto_switch' => (bool) admin_setting('app_enable_auto_switch', true), // 是否开启自动切换
'switch_threshold_ms' => (int) admin_setting('app_switch_threshold_ms', 1000), // 切换阈值(毫秒)
],
'security_config' => [
'tos_url' => admin_setting('tos_url', 'https://example.com/tos'), // 服务条款 URL
'privacy_policy_url' => admin_setting('app_privacy_policy_url', 'https://example.com/privacy'), // 隐私政策 URL
'is_email_verify' => (int) admin_setting('email_verify', 1), // 是否开启邮箱验证 (0/1)
'is_invite_force' => (int) admin_setting('invite_force', 0), // 是否强制邀请码 (0/1)
'email_whitelist_suffix' => (int) admin_setting('email_whitelist_suffix', 0), // 邮箱白名单后缀 (0/1)
'is_captcha' => (int) admin_setting('captcha_enable', 1), // 是否开启验证码 (0/1)
'captcha_type' => admin_setting('captcha_type', 'recaptcha'), // 验证码类型 (recaptcha/turnstile)
'recaptcha_site_key' => admin_setting('recaptcha_site_key', '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'), // reCAPTCHA 站点密钥
'recaptcha_v3_site_key' => admin_setting('recaptcha_v3_site_key', '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'), // reCAPTCHA v3 站点密钥
'recaptcha_v3_score_threshold' => (float) admin_setting('recaptcha_v3_score_threshold', 0.5), // reCAPTCHA v3 分数阈值
'turnstile_site_key' => admin_setting('turnstile_site_key', '0x4AAAAAAAABkMYinukE8nzUg'), // Turnstile 站点密钥
],
'payment_config' => [
'currency' => admin_setting('currency', 'CNY'), // 货币类型
'currency_symbol' => admin_setting('currency_symbol', '¥'), // 货币符号
'withdraw_methods' => admin_setting('app_withdraw_methods', ['alipay', 'wechat', 'bank']), // 提现方式列表
'min_withdraw_amount' => (int) admin_setting('app_min_withdraw_amount', 100), // 最小提现金额(分)
'withdraw_fee_rate' => (float) admin_setting('app_withdraw_fee_rate', 0.01), // 提现手续费率
],
'notification_config' => [
'enable_push_notifications' => (bool) admin_setting('app_enable_push_notifications', true), // 是否开启推送通知
'enable_email_notifications' => (bool) admin_setting('app_enable_email_notifications', true), // 是否开启邮件通知
'enable_sms_notifications' => (bool) admin_setting('app_enable_sms_notifications', false), // 是否开启短信通知
'notification_schedule' => [
'traffic_warning' => (bool) admin_setting('app_notification_traffic_warning', true), // 流量警告通知
'subscription_expiry' => (bool) admin_setting('app_notification_subscription_expiry', true), // 订阅到期通知
'server_maintenance' => (bool) admin_setting('app_notification_server_maintenance', true), // 服务器维护通知
'promotional_offers' => (bool) admin_setting('app_notification_promotional_offers', false), // 促销优惠通知
],
],
'cache_config' => [
'config_cache_duration' => (int) admin_setting('app_config_cache_duration', 3600), // 配置缓存时长(秒)
'server_list_cache_duration' => (int) admin_setting('app_server_list_cache_duration', 1800), // 服务器列表缓存时长(秒)
'user_info_cache_duration' => (int) admin_setting('app_user_info_cache_duration', 900), // 用户信息缓存时长(秒)
],
'last_updated' => time(), // 最后更新时间戳
];
$config['config_hash'] = md5(json_encode($config)); // 配置哈希值(用于校验)
$config = $config ?? [];
return response()->json(['data' => $config]);
}
public function getVersion(Request $request)
{
if (
strpos($request->header('user-agent'), 'tidalab/4.0.0') !== false
|| strpos($request->header('user-agent'), 'tunnelab/4.0.0') !== false
) {
if (strpos($request->header('user-agent'), 'Win64') !== false) {
$data = [
'version' => admin_setting('windows_version'),
'download_url' => admin_setting('windows_download_url')
];
} else {
$data = [
'version' => admin_setting('macos_version'),
'download_url' => admin_setting('macos_download_url')
];
}
} else {
$data = [
'windows_version' => admin_setting('windows_version'),
'windows_download_url' => admin_setting('windows_download_url'),
'macos_version' => admin_setting('macos_version'),
'macos_download_url' => admin_setting('macos_download_url'),
'android_version' => admin_setting('android_version'),
'android_download_url' => admin_setting('android_download_url')
];
}
return $this->success($data);
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\V2\Server;
use App\Http\Controllers\Controller;
use App\Models\ServerMachine;
use App\Models\ServerMachineLoadHistory;
use App\Services\ServerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* machine controller
*/
class MachineController extends Controller
{
/**
* get nodes list for machine
*/
public function nodes(Request $request): JsonResponse
{
$machine = $this->authenticateMachine($request);
$nodes = ServerService::getMachineNodes($machine)
->map(fn($node) => [
'id' => $node->id,
'type' => $node->type,
'name' => $node->name,
])->values();
return response()->json([
'nodes' => $nodes,
'base_config' => [
'push_interval' => (int) admin_setting('server_push_interval', 60),
'pull_interval' => (int) admin_setting('server_pull_interval', 60),
],
]);
}
/**
* report machine status
*/
public function status(Request $request): JsonResponse
{
$request->validate([
'cpu' => 'required|numeric|min:0|max:100',
'mem.total' => 'required|integer|min:0',
'mem.used' => 'required|integer|min:0',
'swap.total' => 'nullable|integer|min:0',
'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;
$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();
$historyData = [
'machine_id' => $machine->id,
'cpu' => (float) $request->input('cpu'),
'mem_total' => (int) $request->input('mem.total'),
'mem_used' => (int) $request->input('mem.used'),
'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) {
ServerMachineLoadHistory::query()
->where('machine_id', $machine->id)
->where('recorded_at', '<', now()->subDay()->timestamp)
->delete();
}
return response()->json(['data' => true]);
}
private function authenticateMachine(Request $request): ServerMachine
{
$request->validate([
'machine_id' => 'required|integer',
'token' => 'required|string',
]);
$machine = ServerMachine::where('id', $request->input('machine_id'))
->where('token', $request->input('token'))
->first();
if (!$machine || !$machine->is_active) {
abort(403, 'Machine not found or disabled');
}
$machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly();
return $machine;
}
}
@@ -3,14 +3,9 @@
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
{
@@ -43,91 +38,37 @@ class ServerController extends Controller
}
/**
* node report api - merge traffic + alive + status
* POST /api/v2/server/node/report
* node report api - merge traffic + alive + status + metrics
*/
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);
ServerService::touchNode($node);
// 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);
}
ServerService::processTraffic($node, $traffic);
}
// handle alive data
$alive = $request->input('alive');
if (is_array($alive) && !empty($alive)) {
UserAliveSyncJob::dispatch($alive, $nodeType, $nodeId);
ServerService::processAlive($node->id, $alive);
}
// 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);
}
ServerService::processOnline($node, $online);
}
// 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);
ServerService::processStatus($node, $status);
}
// handle node metrics (Metrics)
$metrics = $request->input('metrics');
if (is_array($metrics) && !empty($metrics)) {
ServerService::updateMetrics($node, $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);
}
}
+86 -27
View File
@@ -1,10 +1,10 @@
<?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;
@@ -13,7 +13,46 @@ class Server
{
public function handle(Request $request, Closure $next, ?string $nodeType = null)
{
$this->validateRequest($request);
// 优先尝试 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' => [
'string', 'required',
function ($attribute, $value, $fail) {
if ($value !== admin_setting('server_token')) {
$fail("Invalid {$attribute}");
}
},
],
'node_id' => 'required',
'node_type' => [
'nullable',
function ($attribute, $value, $fail) use ($request) {
if ($value === 'v2node') {
$value = null;
}
if (!ServerModel::isValidType($value)) {
$fail('Invalid node type specified');
return;
}
$request->merge([$attribute => ServerModel::normalizeType($value)]);
},
]
]);
$nodeType = $request->input('node_type', $nodeType);
$normalizedNodeType = ServerModel::normalizeType($nodeType);
$serverInfo = ServerService::getServer(
@@ -25,35 +64,55 @@ class Server
}
$request->attributes->set('node_info', $serverInfo);
return $next($request);
}
private function validateRequest(Request $request): void
/**
* 新模式: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([
'token' => [
'string',
'required',
function ($attribute, $value, $fail) {
if ($value !== admin_setting('server_token')) {
$fail("Invalid {$attribute}");
}
},
],
'node_id' => 'required',
'node_type' => [
'nullable',
function ($attribute, $value, $fail) use ($request) {
if ($value === "v2node") {
$value = null;
}
if (!ServerModel::isValidType($value)) {
$fail("Invalid node type specified");
return;
}
$request->merge([$attribute => ServerModel::normalizeType($value)]);
},
]
'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);
}
}
-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' => '',
+129 -20
View File
@@ -25,6 +25,22 @@ class ServerSave extends FormRequest
'multiplex.brutal.down_mbps' => 'nullable|integer',
];
private const ECH_RULES = [
'enabled' => 'nullable|boolean',
'config' => 'nullable|string',
'query_server_name' => 'nullable|string',
'key' => 'nullable|string',
];
private const REALITY_RULES = [
'reality_settings.allow_insecure' => 'nullable|boolean',
'reality_settings.server_name' => 'nullable|string',
'reality_settings.server_port' => 'nullable|integer',
'reality_settings.public_key' => 'nullable|string',
'reality_settings.private_key' => 'nullable|string',
'reality_settings.short_id' => 'nullable|string',
];
private const PROTOCOL_RULES = [
'shadowsocks' => [
'cipher' => 'required|string',
@@ -38,10 +54,10 @@ class ServerSave extends FormRequest
'tls' => 'required|integer',
'network' => 'required|string',
'network_settings' => 'nullable|array',
'tls_settings.server_name' => 'nullable|string',
'tls_settings.allow_insecure' => 'nullable|boolean',
'rules' => 'nullable|array',
],
'trojan' => [
'tls' => 'nullable|integer',
'network' => 'required|string',
'network_settings' => 'nullable|array',
'server_name' => 'nullable|string',
@@ -53,8 +69,6 @@ class ServerSave extends FormRequest
'obfs.open' => 'nullable|boolean',
'obfs.type' => 'string|nullable',
'obfs.password' => 'string|nullable',
'tls.server_name' => 'nullable|string',
'tls.allow_insecure' => 'nullable|boolean',
'bandwidth.up' => 'nullable|integer',
'bandwidth.down' => 'nullable|integer',
'hop_interval' => 'integer|nullable',
@@ -64,28 +78,29 @@ class ServerSave extends FormRequest
'network' => 'required|string',
'network_settings' => 'nullable|array',
'flow' => 'nullable|string',
'tls_settings.server_name' => 'nullable|string',
'tls_settings.allow_insecure' => 'nullable|boolean',
'reality_settings.allow_insecure' => 'nullable|boolean',
'reality_settings.server_name' => 'nullable|string',
'reality_settings.server_port' => 'nullable|integer',
'reality_settings.public_key' => 'nullable|string',
'reality_settings.private_key' => 'nullable|string',
'reality_settings.short_id' => 'nullable|string',
'encryption' => 'nullable|array',
'encryption.enabled' => 'nullable|boolean',
'encryption.encryption' => 'nullable|string',
'encryption.decryption' => 'nullable|string',
],
'socks' => [
'tls' => 'nullable|integer',
],
'naive' => [
'tls' => 'required|integer',
'tls_settings' => 'nullable|array',
],
'http' => [
'tls' => 'required|integer',
'tls_settings' => 'nullable|array',
],
'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'
'traffic_pattern' => 'string',
],
'anytls' => [
'tls' => 'nullable|array',
@@ -105,6 +120,8 @@ class ServerSave extends FormRequest
'group_ids' => 'nullable|array',
'route_ids' => 'nullable|array',
'parent_id' => 'nullable|integer',
'machine_id' => 'nullable|integer',
'enabled' => 'nullable|boolean',
'host' => 'required',
'port' => 'required',
'server_port' => 'required',
@@ -121,18 +138,99 @@ class ServerSave extends FormRequest
'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',
'protocol_settings' => 'array',
'transfer_enable' => 'nullable|integer|min:0',
];
}
private function getProtocolRules(string $type): array
{
$rules = self::PROTOCOL_RULES[$type] ?? [];
return match ($type) {
'vmess' => array_merge(
$rules,
$this->buildTlsSettingsRules(),
self::MULTIPLEX_RULES,
self::UTLS_RULES,
),
'trojan' => array_merge(
$rules,
$this->buildTlsSettingsRules(includeRoot: true),
self::REALITY_RULES,
self::MULTIPLEX_RULES,
self::UTLS_RULES,
),
'hysteria' => array_merge(
$rules,
$this->buildTlsObjectRules(),
),
'tuic' => array_merge(
$rules,
$this->buildTlsObjectRules(),
),
'mieru' => array_merge(
$rules,
self::MULTIPLEX_RULES,
),
'vless' => array_merge(
$rules,
$this->buildTlsSettingsRules(),
self::REALITY_RULES,
self::MULTIPLEX_RULES,
self::UTLS_RULES,
),
'socks', 'naive', 'http' => array_merge(
$rules,
$this->buildTlsSettingsRules(includeRoot: $type !== 'socks'),
),
'anytls' => array_merge(
$rules,
$this->buildTlsObjectRules(includeRoot: true),
),
default => $rules,
};
}
private function buildTlsSettingsRules(bool $includeRoot = false): array
{
return array_merge(
$includeRoot ? ['tls_settings' => 'nullable|array'] : [],
[
'tls_settings.server_name' => 'nullable|string',
'tls_settings.allow_insecure' => 'nullable|boolean',
'tls_settings.ech' => 'nullable|array',
],
$this->prefixRules('tls_settings.ech.', self::ECH_RULES),
);
}
private function buildTlsObjectRules(bool $includeRoot = false): array
{
return array_merge(
$includeRoot ? ['tls' => 'nullable|array'] : [],
[
'tls.server_name' => 'nullable|string',
'tls.allow_insecure' => 'nullable|boolean',
'tls.ech' => 'nullable|array',
],
$this->prefixRules('tls.ech.', self::ECH_RULES),
);
}
private function prefixRules(string $prefix, array $rules): array
{
$result = [];
foreach ($rules as $field => $rule) {
$result[$prefix . $field] = $rule;
}
return $result;
}
public function rules(): array
{
$type = $this->input('type');
$rules = $this->getBaseRules();
$protocolRules = self::PROTOCOL_RULES[$type] ?? [];
if (in_array($type, ['vmess', 'vless', 'trojan', 'mieru'])) {
$protocolRules = array_merge($protocolRules, self::MULTIPLEX_RULES, self::UTLS_RULES);
}
$protocolRules = $this->getProtocolRules($type);
foreach ($protocolRules as $field => $rule) {
$rules['protocol_settings.' . $field] = $rule;
@@ -165,6 +263,14 @@ class ServerSave extends FormRequest
'protocol_settings.multiplex.brutal.down_mbps' => 'Brutal下行速率',
'protocol_settings.utls.enabled' => 'uTLS',
'protocol_settings.utls.fingerprint' => 'uTLS指纹',
'protocol_settings.tls_settings.ech.enabled' => 'ECH',
'protocol_settings.tls_settings.ech.config' => 'ECH配置',
'protocol_settings.tls_settings.ech.query_server_name' => 'ECH查询域名',
'protocol_settings.tls_settings.ech.key' => 'ECH密钥',
'protocol_settings.tls.ech.enabled' => 'ECH',
'protocol_settings.tls.ech.config' => 'ECH配置',
'protocol_settings.tls.ech.query_server_name' => 'ECH查询域名',
'protocol_settings.tls.ech.key' => 'ECH密钥',
];
}
@@ -190,9 +296,12 @@ class ServerSave extends FormRequest
'tlsSettings.array' => 'tls配置有误',
'dnsSettings.array' => 'dns配置有误',
'protocol_settings.*.required' => ':attribute 不能为空',
'protocol_settings.*.required_if' => ':attribute 不能为空',
'protocol_settings.*.string' => ':attribute 必须是字符串',
'protocol_settings.*.integer' => ':attribute 必须是整数',
'protocol_settings.*.in' => ':attribute 的值不合法',
'transfer_enable.integer' => '流量上限必须是整数',
'transfer_enable.min' => '流量上限不能小于0',
];
}
}
+33 -7
View File
@@ -2,10 +2,12 @@
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;
use App\Http\Controllers\V2\Admin\Server\ManageController;
use App\Http\Controllers\V2\Admin\Server\MachineController;
use App\Http\Controllers\V2\Admin\OrderController;
use App\Http\Controllers\V2\Admin\UserController;
use App\Http\Controllers\V2\Admin\StatController;
@@ -40,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'
@@ -66,22 +79,35 @@ class AdminRoute
$router->post('/save', [RouteController::class, 'save']);
$router->post('/drop', [RouteController::class, 'drop']);
});
// 节点管理接口
$router->group([
'prefix' => 'server/manage'
], function ($router) {
$router->get('/getNodes', [ManageController::class, 'getNodes']);
$router->post('/sort', [ManageController::class, 'sort']);
});
// 节点更新接口
$router->group([
'prefix' => 'server/manage'
], function ($router) {
$router->post('/update', [ManageController::class, 'update']);
$router->post('/save', [ManageController::class, 'save']);
$router->post('/drop', [ManageController::class, 'drop']);
$router->post('/copy', [ManageController::class, 'copy']);
$router->post('/sort', [ManageController::class, 'sort']);
$router->post('/batchDelete', [ManageController::class, 'batchDelete']);
$router->post('/batchUpdate', [ManageController::class, 'batchUpdate']);
$router->post('/resetTraffic', [ManageController::class, 'resetTraffic']);
$router->post('/batchResetTraffic', [ManageController::class, 'batchResetTraffic']);
$router->get('/generateEchKey', [ManageController::class, 'generateEchKey']);
});
// 机器管理接口
$router->group([
'prefix' => 'server/machine'
], function ($router) {
$router->get('/fetch', [MachineController::class, 'fetch']);
$router->post('/save', [MachineController::class, 'save']);
$router->post('/drop', [MachineController::class, 'drop']);
$router->post('/resetToken', [MachineController::class, 'resetToken']);
$router->get('/getToken', [MachineController::class, 'getToken']);
$router->get('/installCommand', [MachineController::class, 'installCommand']);
$router->get('/nodes', [MachineController::class, 'nodes']);
$router->get('/history', [MachineController::class, 'history']);
});
// Order
+9 -2
View File
@@ -5,18 +5,18 @@ 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 App\Http\Controllers\V2\Server\MachineController;
use Illuminate\Contracts\Routing\Registrar;
class ServerRoute
{
public function map(Registrar $router)
{
$router->group([
'prefix' => 'server',
'middleware' => 'server'
], function ($route) {
$route->post('handshake', [ServerController::class, 'handshake']);
$route->match(['GET', 'POST'], 'handshake', [ServerController::class, 'handshake']);
$route->post('report', [ServerController::class, 'report']);
$route->get('config', [UniProxyController::class, 'config']);
$route->get('user', [UniProxyController::class, 'user']);
@@ -25,5 +25,12 @@ class ServerRoute
$route->get('alivelist', [UniProxyController::class, 'alivelist']);
$route->post('status', [UniProxyController::class, 'status']);
});
$router->group([
'prefix' => 'server/machine',
], function ($route) {
$route->post('nodes', [MachineController::class, 'nodes']);
$route->post('status', [MachineController::class, 'status']);
});
}
}
+13
View File
@@ -3,12 +3,14 @@
namespace App\Jobs;
use App\Models\Server;
use App\Models\StatServer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -59,12 +61,23 @@ class StatServerJob implements ShouldQueue
try {
$this->processServerStat($u, $d, $recordAt);
$this->updateServerTraffic($u, $d);
} catch (\Exception $e) {
Log::error('StatServerJob failed for server ' . $this->server['id'] . ': ' . $e->getMessage());
throw $e;
}
}
protected function updateServerTraffic(int $u, int $d): void
{
DB::table('v2_server')
->where('id', $this->server['id'])
->incrementEach(
['u' => $u, 'd' => $d],
['updated_at' => Carbon::now()]
);
}
protected function processServerStat(int $u, int $d, int $recordAt): void
{
$driver = config('database.default');
+8 -8
View File
@@ -85,8 +85,8 @@ class StatUserJob implements ShouldQueue
if ($existingRecord) {
$existingRecord->update([
'u' => $existingRecord->u + ($v[0] * $this->server['rate']),
'd' => $existingRecord->d + ($v[1] * $this->server['rate']),
'u' => $existingRecord->u + intval($v[0] * $this->server['rate']),
'd' => $existingRecord->d + intval($v[1] * $this->server['rate']),
'updated_at' => time(),
]);
} else {
@@ -95,8 +95,8 @@ class StatUserJob implements ShouldQueue
'server_rate' => $this->server['rate'],
'record_at' => $recordAt,
'record_type' => $this->recordType,
'u' => ($v[0] * $this->server['rate']),
'd' => ($v[1] * $this->server['rate']),
'u' => intval($v[0] * $this->server['rate']),
'd' => intval($v[1] * $this->server['rate']),
'created_at' => time(),
'updated_at' => time(),
]);
@@ -112,8 +112,8 @@ class StatUserJob implements ShouldQueue
'server_rate' => $this->server['rate'],
'record_at' => $recordAt,
'record_type' => $this->recordType,
'u' => ($v[0] * $this->server['rate']),
'd' => ($v[1] * $this->server['rate']),
'u' => intval($v[0] * $this->server['rate']),
'd' => intval($v[1] * $this->server['rate']),
'created_at' => time(),
'updated_at' => time(),
],
@@ -133,8 +133,8 @@ class StatUserJob implements ShouldQueue
{
$table = (new StatUser())->getTable();
$now = time();
$u = ($v[0] * $this->server['rate']);
$d = ($v[1] * $this->server['rate']);
$u = intval($v[0] * $this->server['rate']);
$d = intval($v[1] * $this->server['rate']);
$sql = "INSERT INTO {$table} (user_id, server_rate, record_at, record_type, u, d, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+7 -5
View File
@@ -8,6 +8,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
class TrafficFetchJob implements ShouldQueue
{
@@ -19,11 +20,6 @@ class TrafficFetchJob implements ShouldQueue
public $tries = 1;
public $timeout = 20;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(array $server, array $data, $protocol, int $timestamp)
{
$this->onQueue('traffic_fetch');
@@ -35,6 +31,8 @@ class TrafficFetchJob implements ShouldQueue
public function handle(): void
{
$userIds = array_keys($this->data);
foreach ($this->data as $uid => $v) {
User::where('id', $uid)
->incrementEach(
@@ -45,5 +43,9 @@ class TrafficFetchJob implements ShouldQueue
['t' => time()]
);
}
if (!empty($userIds)) {
Redis::sadd('traffic:pending_check', ...$userIds);
}
}
}
-108
View File
@@ -1,108 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use App\Services\UserOnlineService;
use Illuminate\Support\Facades\Log;
class UserAliveSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const CACHE_PREFIX = 'ALIVE_IP_USER_';
private const CACHE_TTL = 120;
private const NODE_DATA_EXPIRY = 100;
public function __construct(
private readonly array $data,
private readonly string $nodeType,
private readonly int $nodeId
) {
$this->onQueue('user_alive_sync');
}
public function handle(): void
{
try {
$updateAt = time();
$nowTs = time();
$now = now();
$nodeKey = $this->nodeType . $this->nodeId;
$userUpdates = [];
foreach ($this->data as $uid => $ips) {
$cacheKey = self::CACHE_PREFIX . $uid;
$ipsArray = Cache::get($cacheKey, []);
$ipsArray = [
...collect($ipsArray)
->filter(fn(mixed $value): bool => is_array($value) && ($updateAt - ($value['lastupdateAt'] ?? 0) <= self::NODE_DATA_EXPIRY)),
$nodeKey => [
'aliveips' => $ips,
'lastupdateAt' => $updateAt,
],
];
$count = UserOnlineService::calculateDeviceCount($ipsArray);
$ipsArray['alive_ip'] = $count;
Cache::put($cacheKey, $ipsArray, now()->addSeconds(self::CACHE_TTL));
$userUpdates[] = [
'id' => (int) $uid,
'count' => (int) $count,
];
}
if (!empty($userUpdates)) {
$allIds = collect($userUpdates)
->pluck('id')
->filter()
->map(fn($v) => (int) $v)
->unique()
->values()
->all();
if (!empty($allIds)) {
$existingIds = User::query()
->whereIn('id', $allIds)
->pluck('id')
->map(fn($v) => (int) $v)
->all();
if (!empty($existingIds)) {
collect($userUpdates)
->filter(fn($row) => in_array((int) ($row['id'] ?? 0), $existingIds, true))
->chunk(1000)
->each(function ($chunk) use ($now) {
collect($chunk)->each(function ($update) use ($now) {
$id = (int) ($update['id'] ?? 0);
$count = (int) ($update['count'] ?? 0);
if ($id > 0) {
User::query()
->whereKey($id)
->update([
'online_count' => $count,
'last_online_at' => $now,
]);
}
});
});
}
}
}
} catch (\Throwable $e) {
Log::error('UserAliveSyncJob failed', [
'error' => $e->getMessage(),
]);
$this->fail($e);
}
}
}
+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;
}
}
+80 -47
View File
@@ -52,6 +52,10 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
* @property int|null $d 下行流量
* @property int|null $total 总流量
* @property-read array|null $load_status 负载状态(包含CPU、内存、交换区、磁盘信息)
*
* @property int $transfer_enable 流量上限,0或者null表示不限制
* @property int $u 当前上传流量
* @property int $d 当前下载流量
*/
class Server extends Model
{
@@ -120,10 +124,15 @@ class Server extends Model
'last_check_at' => 'integer',
'last_push_at' => 'integer',
'show' => 'boolean',
'enabled' => 'boolean',
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
'rate_time_ranges' => 'array',
'rate_time_enable' => 'boolean',
'transfer_enable' => 'integer',
'u' => 'integer',
'd' => 'integer',
'machine_id' => 'integer',
];
private const MULTIPLEX_CONFIGURATION = [
@@ -148,6 +157,20 @@ class Server extends Model
]
];
private const REALITY_CONFIGURATION = [
'reality_settings' => [
'type' => 'object',
'fields' => [
'server_name' => ['type' => 'string', 'default' => null],
'server_port' => ['type' => 'string', 'default' => null],
'public_key' => ['type' => 'string', 'default' => null],
'private_key' => ['type' => 'string', 'default' => null],
'short_id' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false],
]
]
];
private const UTLS_CONFIGURATION = [
'utls' => [
'type' => 'object',
@@ -158,12 +181,47 @@ class Server extends Model
]
];
private const ECH_CONFIGURATION = [
'ech' => [
'type' => 'object',
'fields' => [
'enabled' => ['type' => 'boolean', 'default' => false],
'config' => ['type' => 'string', 'default' => null],
'query_server_name' => ['type' => 'string', 'default' => null],
'key' => ['type' => 'string', 'default' => null],
'key_path' => ['type' => 'string', 'default' => null],
'config_path' => ['type' => 'string', 'default' => null],
]
]
];
private const TLS_SETTINGS_CONFIGURATION = [
'type' => 'object',
'fields' => [
'server_name' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false],
...self::ECH_CONFIGURATION,
]
];
private const TLS_CONFIGURATION = [
'type' => 'object',
'fields' => [
'server_name' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false],
...self::ECH_CONFIGURATION,
]
];
private const PROTOCOL_CONFIGURATIONS = [
self::TYPE_TROJAN => [
'tls' => ['type' => 'integer', 'default' => 1],
'network' => ['type' => 'string', 'default' => null],
'network_settings' => ['type' => 'array', 'default' => null],
'server_name' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false],
'tls_settings' => self::TLS_SETTINGS_CONFIGURATION,
...self::REALITY_CONFIGURATION,
...self::MULTIPLEX_CONFIGURATION,
...self::UTLS_CONFIGURATION
],
@@ -172,27 +230,26 @@ class Server extends Model
'network' => ['type' => 'string', 'default' => null],
'rules' => ['type' => 'array', 'default' => null],
'network_settings' => ['type' => 'array', 'default' => null],
'tls_settings' => ['type' => 'array', 'default' => null],
'tls_settings' => self::TLS_SETTINGS_CONFIGURATION,
...self::MULTIPLEX_CONFIGURATION,
...self::UTLS_CONFIGURATION
],
self::TYPE_VLESS => [
'tls' => ['type' => 'integer', 'default' => 0],
'tls_settings' => ['type' => 'array', 'default' => null],
'tls_settings' => self::TLS_SETTINGS_CONFIGURATION,
'flow' => ['type' => 'string', 'default' => null],
'network' => ['type' => 'string', 'default' => null],
'network_settings' => ['type' => 'array', 'default' => null],
'reality_settings' => [
'encryption' => [
'type' => 'object',
'default' => null,
'fields' => [
'allow_insecure' => ['type' => 'boolean', 'default' => false],
'server_port' => ['type' => 'string', 'default' => null],
'server_name' => ['type' => 'string', 'default' => null],
'public_key' => ['type' => 'string', 'default' => null],
'private_key' => ['type' => 'string', 'default' => null],
'short_id' => ['type' => 'string', 'default' => null]
'enabled' => ['type' => 'boolean', 'default' => false],
'encryption' => ['type' => 'string', 'default' => null], // 客户端公钥
'decryption' => ['type' => 'string', 'default' => null], // 服务端私钥
]
],
'network' => ['type' => 'string', 'default' => null],
'network_settings' => ['type' => 'array', 'default' => null],
...self::REALITY_CONFIGURATION,
...self::MULTIPLEX_CONFIGURATION,
...self::UTLS_CONFIGURATION
],
@@ -220,13 +277,7 @@ class Server extends Model
'password' => ['type' => 'string', 'default' => null]
]
],
'tls' => [
'type' => 'object',
'fields' => [
'server_name' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false]
]
],
'tls' => self::TLS_CONFIGURATION,
'hop_interval' => ['type' => 'integer', 'default' => null]
],
self::TYPE_TUIC => [
@@ -234,13 +285,7 @@ class Server extends Model
'congestion_control' => ['type' => 'string', 'default' => 'cubic'],
'alpn' => ['type' => 'array', 'default' => ['h3']],
'udp_relay_mode' => ['type' => 'string', 'default' => 'native'],
'tls' => [
'type' => 'object',
'fields' => [
'server_name' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false]
]
]
'tls' => self::TLS_CONFIGURATION
],
self::TYPE_ANYTLS => [
'padding_scheme' => [
@@ -257,36 +302,19 @@ class Server extends Model
"7=500-1000"
]
],
'tls' => [
'type' => 'object',
'fields' => [
'server_name' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false]
]
]
'tls' => self::TLS_CONFIGURATION
],
self::TYPE_SOCKS => [
'tls' => ['type' => 'integer', 'default' => 0],
'tls_settings' => [
'type' => 'object',
'fields' => [
'allow_insecure' => ['type' => 'boolean', 'default' => false]
]
]
'tls_settings' => self::TLS_SETTINGS_CONFIGURATION
],
self::TYPE_NAIVE => [
'tls' => ['type' => 'integer', 'default' => 0],
'tls_settings' => ['type' => 'array', 'default' => null]
'tls_settings' => self::TLS_SETTINGS_CONFIGURATION
],
self::TYPE_HTTP => [
'tls' => ['type' => 'integer', 'default' => 0],
'tls_settings' => [
'type' => 'object',
'fields' => [
'allow_insecure' => ['type' => 'boolean', 'default' => false],
'server_name' => ['type' => 'string', 'default' => null]
]
]
'tls_settings' => self::TLS_SETTINGS_CONFIGURATION
],
self::TYPE_MIERU => [
'transport' => ['type' => 'string', 'default' => 'TCP'],
@@ -394,9 +422,14 @@ class Server extends Model
return $this->hasMany(StatServer::class, 'server_id', 'id');
}
public function machine(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(ServerMachine::class, 'machine_id');
}
public function groups()
{
return ServerGroup::whereIn('id', $this->group_ids)->get();
return ServerGroup::whereIn('id', $this->group_ids ?? [])->get();
}
public function routes()
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* App\Models\ServerMachine
*
* @property int $id
* @property string $name 机器名称
* @property string $token 认证 Token
* @property string|null $notes 备注
* @property bool $is_active 是否启用
* @property int|null $last_seen_at 最后心跳时间
* @property array|null $load_status 负载状态
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
*
* @property-read \Illuminate\Database\Eloquent\Collection<int, Server> $servers 关联的节点
*/
class ServerMachine extends Model
{
protected $table = 'v2_server_machine';
protected $guarded = ['id'];
protected $casts = [
'is_active' => 'boolean',
'last_seen_at' => 'integer',
'load_status' => 'array',
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
];
protected $hidden = ['token'];
public function servers(): HasMany
{
return $this->hasMany(Server::class, 'machine_id');
}
public function loadHistory(): HasMany
{
return $this->hasMany(ServerMachineLoadHistory::class, 'machine_id');
}
/**
* 生成新的随机 Token
*/
public static function generateToken(): string
{
return Str::random(32);
}
/**
* 更新最后心跳时间
*/
public function updateHeartbeat(): bool
{
return $this->forceFill(['last_seen_at' => now()->timestamp])->save();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServerMachineLoadHistory extends Model
{
protected $table = 'v2_server_machine_load_history';
protected $guarded = ['id'];
protected $casts = [
'cpu' => 'float',
'mem_total' => 'integer',
'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',
];
public function machine(): BelongsTo
{
return $this->belongsTo(ServerMachine::class, 'machine_id');
}
}
+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;
}
}
+16
View File
@@ -3,6 +3,8 @@
namespace App\Models;
use App\Utils\Helper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -81,6 +83,20 @@ class User extends Authenticatable
public const COMMISSION_TYPE_SYSTEM = 0;
public const COMMISSION_TYPE_PERIOD = 1;
public const COMMISSION_TYPE_ONETIME = 2;
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower(trim($value)),
);
}
/**
* 按邮箱查询(大小写不敏感,兼容所有数据库)
*/
public function scopeByEmail(Builder $query, string $email): Builder
{
return $query->where('email', strtolower(trim($email)));
}
// 获取邀请人信息
public function invite_user(): BelongsTo
+47 -18
View File
@@ -7,31 +7,60 @@ use App\Services\NodeSyncService;
class ServerObserver
{
public bool $afterCommit = true;
public function created(Server $server): void
{
$this->notifyMachineNodesChanged($server->machine_id);
}
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',
])
) {
if ($server->wasChanged('group_ids')) {
NodeSyncService::notifyFullSync($server->id);
} elseif ($server->wasChanged([
'server_port',
'protocol_settings',
'type',
'route_ids',
'custom_outbounds',
'custom_routes',
'cert_config',
])) {
NodeSyncService::notifyConfigUpdated($server->id);
}
if ($server->wasChanged(['machine_id', 'enabled'])) {
$this->notifyMachineChange(
$server->machine_id,
$server->getOriginal('machine_id')
);
}
}
public function deleted(Server $server): void
{
NodeSyncService::notifyConfigUpdated($server->id);
$this->notifyMachineChange(null, $server->getOriginal('machine_id') ?: $server->machine_id);
}
private function notifyMachineChange(?int $newMachineId, ?int $oldMachineId): void
{
$notified = [];
if ($newMachineId) {
NodeSyncService::notifyMachineNodesChanged($newMachineId);
$notified[] = $newMachineId;
}
if ($oldMachineId && !in_array($oldMachineId, $notified, true)) {
NodeSyncService::notifyMachineNodesChanged($oldMachineId);
}
}
private function notifyMachineNodesChanged(?int $machineId): void
{
if ($machineId) {
NodeSyncService::notifyMachineNodesChanged($machineId);
}
}
}
+10 -3
View File
@@ -8,6 +8,8 @@ use App\Services\TrafficResetService;
class UserObserver
{
public bool $afterCommit = true;
public function __construct(
private readonly TrafficResetService $trafficResetService
) {
@@ -15,12 +17,17 @@ class UserObserver
public function updated(User $user): void
{
if ($user->isDirty(['plan_id', 'expired_at'])) {
// With $afterCommit = true, isDirty() is always false after commit.
// Use wasChanged() to detect what was actually modified.
$syncFields = ['group_id', 'uuid', 'speed_limit', 'device_limit', 'banned', 'expired_at', 'transfer_enable', 'u', 'd', 'plan_id'];
$needsSync = $user->wasChanged($syncFields);
$oldGroupId = $user->wasChanged('group_id') ? $user->getOriginal('group_id') : null;
if ($user->wasChanged(['plan_id', 'expired_at'])) {
$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;
if ($needsSync) {
NodeUserSyncJob::dispatch($user->id, 'updated', $oldGroupId);
}
}
+5 -4
View File
@@ -199,8 +199,9 @@ class Clash extends AbstractProtocol
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
$array['network'] = data_get($protocol_settings, 'network_settings.header.type');
if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') {
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'none');
$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', ['/'])
@@ -237,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':
+135 -6
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,
],
@@ -58,6 +79,66 @@ class ClashMeta extends AbstractProtocol
'flclash.hysteria.protocol_settings.version' => [
2 => '0.8.0',
],
'meta.vmess.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'meta.vless.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'meta.trojan.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'meta.anytls.protocol_settings.tls.ech.enabled' => [
1 => '1.19.9',
],
'verge.vmess.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'verge.vless.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'verge.trojan.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'verge.anytls.protocol_settings.tls.ech.enabled' => [
1 => '1.19.9',
],
'flclash.vmess.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'flclash.vless.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'flclash.trojan.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'flclash.anytls.protocol_settings.tls.ech.enabled' => [
1 => '1.19.9',
],
'nekobox.vmess.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'nekobox.vless.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'nekobox.trojan.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'nekobox.anytls.protocol_settings.tls.ech.enabled' => [
1 => '1.19.9',
],
'clashmetaforandroid.vmess.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'clashmetaforandroid.vless.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'clashmetaforandroid.trojan.protocol_settings.tls_settings.ech.enabled' => [
1 => '1.19.9',
],
'clashmetaforandroid.anytls.protocol_settings.tls.ech.enabled' => [
1 => '1.19.9',
],
];
public function handle()
@@ -264,6 +345,7 @@ class ClashMeta extends AbstractProtocol
$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::appendEch($array, data_get($protocol_settings, 'tls_settings.ech'));
}
self::appendUtls($array, $protocol_settings);
@@ -271,8 +353,9 @@ class ClashMeta extends AbstractProtocol
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') {
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'none');
$array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
if ($headerType === 'http') {
if (
$httpOpts = array_filter([
'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'),
@@ -331,6 +414,10 @@ class ClashMeta extends AbstractProtocol
'cipher' => 'auto',
'udp' => true,
'flow' => data_get($protocol_settings, 'flow'),
'encryption' => match (data_get($protocol_settings, 'encryption.enabled')) {
true => data_get($protocol_settings, 'encryption.encryption', 'none'),
default => 'none'
},
'tls' => false
];
@@ -341,6 +428,7 @@ class ClashMeta extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['servername'] = $serverName;
}
self::appendEch($array, data_get($protocol_settings, 'tls_settings.ech'));
self::appendUtls($array, $protocol_settings);
break;
case 2:
@@ -401,6 +489,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;
}
@@ -420,10 +520,27 @@ class ClashMeta extends AbstractProtocol
'port' => $server['port'],
'password' => $password,
'udp' => true,
'skip-cert-verify' => (bool) data_get($protocol_settings, 'allow_insecure', false)
];
if ($serverName = data_get($protocol_settings, 'server_name')) {
$array['sni'] = $serverName;
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
switch ($tlsMode) {
case 2: // Reality
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false);
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
$array['sni'] = $serverName;
}
$array['reality-opts'] = [
'public-key' => data_get($protocol_settings, 'reality_settings.public_key'),
'short-id' => data_get($protocol_settings, 'reality_settings.short_id'),
];
break;
default: // Standard TLS
$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'));
break;
}
self::appendUtls($array, $protocol_settings);
@@ -563,6 +680,7 @@ class ClashMeta extends AbstractProtocol
if ($allowInsecure = data_get($protocol_settings, 'tls.allow_insecure')) {
$array['skip-cert-verify'] = (bool) $allowInsecure;
}
self::appendEch($array, data_get($protocol_settings, 'tls.ech'));
return $array;
}
@@ -666,7 +784,7 @@ class ClashMeta extends AbstractProtocol
]);
if (data_get($multiplex, 'brutal.enabled')) {
$array['smux']['brutal'] = [
$array['smux']['brutal-opts'] = [
'enabled' => true,
'up' => data_get($multiplex, 'brutal.up_mbps'),
'down' => data_get($multiplex, 'brutal.down_mbps'),
@@ -684,4 +802,15 @@ class ClashMeta extends AbstractProtocol
}
}
}
protected static function appendEch(&$array, $ech): void
{
if ($normalized = Helper::normalizeEchSettings($ech)) {
$array['ech-opts'] = array_filter([
'enable' => true,
'config' => Helper::toMihomoEchConfig(data_get($normalized, 'config')),
'query-server-name' => data_get($normalized, 'query_server_name'),
], fn($value) => $value !== null);
}
}
}
+88 -26
View File
@@ -48,7 +48,9 @@ class General extends AbstractProtocol
default => '',
};
}
return response(base64_encode($uri))->header('content-type', 'text/plain');
return response(base64_encode($uri))
->header('content-type', 'text/plain')
->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}");
}
public static function buildShadowsocks($password, $server)
@@ -59,14 +61,14 @@ class General extends AbstractProtocol
$str = str_replace(
['+', '/', '='],
['-', '_', ''],
base64_encode("{$protocol_settings['cipher']}:{$password}")
base64_encode(data_get($protocol_settings, 'cipher') . ":{$password}")
);
$addr = Helper::wrapIPv6($server['host']);
$plugin = data_get($protocol_settings, 'plugin');
$plugin_opts = data_get($protocol_settings, 'plugin_opts');
$url = "ss://{$str}@{$addr}:{$server['port']}";
if ($plugin && $plugin_opts) {
$url .= '/?' . 'plugin=' . $plugin . ';' . rawurlencode($plugin_opts);
$url .= '/?' . 'plugin=' . rawurlencode($plugin . ';' . $plugin_opts);
}
$url .= "#{$name}\r\n";
return $url;
@@ -82,17 +84,20 @@ class General extends AbstractProtocol
"port" => (string) $server['port'],
"id" => $uuid,
"aid" => '0',
"net" => $server['protocol_settings']['network'],
"net" => data_get($server, 'protocol_settings.network'),
"type" => "none",
"host" => "",
"path" => "",
"tls" => $protocol_settings['tls'] ? "tls" : "",
"tls" => data_get($protocol_settings, 'tls') ? "tls" : "",
];
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config['sni'] = $serverName;
}
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$config['fp'] = $fp;
}
switch ($protocol_settings['network']) {
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') {
$config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'http');
@@ -130,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;
}
@@ -146,12 +162,15 @@ class General extends AbstractProtocol
$config = [
'mode' => 'multi', //grpc传输模式
'security' => '', //传输层安全 tls/reality
'encryption' => 'none', //加密方式
'type' => $server['protocol_settings']['network'], //传输协议
'flow' => $protocol_settings['flow'] ? $protocol_settings['flow'] : null,
'encryption' => match (data_get($protocol_settings, 'encryption.enabled')) {
true => data_get($protocol_settings, 'encryption.encryption', 'none'),
default => 'none'
},
'type' => data_get($server, 'protocol_settings.network'), //传输协议
'flow' => data_get($protocol_settings, 'flow'),
];
// 处理TLS
switch ($server['protocol_settings']['tls']) {
switch (data_get($server, 'protocol_settings.tls')) {
case 1:
$config['security'] = "tls";
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
@@ -160,6 +179,9 @@ class General extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config['sni'] = $serverName;
}
if (data_get($protocol_settings, 'tls_settings.allow_insecure')) {
$config['allowInsecure'] = '1';
}
break;
case 2: //reality
$config['security'] = "reality";
@@ -176,7 +198,7 @@ class General extends AbstractProtocol
break;
}
// 处理传输协议
switch ($server['protocol_settings']['network']) {
switch (data_get($server, 'protocol_settings.network')) {
case 'ws':
if ($path = data_get($protocol_settings, 'network_settings.path'))
$config['path'] = $path;
@@ -205,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;
}
@@ -224,12 +249,31 @@ class General extends AbstractProtocol
$protocol_settings = $server['protocol_settings'];
$name = rawurlencode($server['name']);
$array = [];
$array['allowInsecure'] = $protocol_settings['allow_insecure'];
if ($serverName = data_get($protocol_settings, 'server_name')) {
$array['peer'] = $serverName;
$array['sni'] = $serverName;
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
switch ($tlsMode) {
case 2: // Reality
$array['security'] = 'reality';
$array['pbk'] = data_get($protocol_settings, 'reality_settings.public_key');
$array['sid'] = data_get($protocol_settings, 'reality_settings.short_id');
$array['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$array['fp'] = $fp;
}
break;
default: // Standard TLS
$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;
}
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
$array['fp'] = $fp;
}
break;
}
switch ($server['protocol_settings']['network']) {
switch (data_get($server, 'protocol_settings.network')) {
case 'ws':
$array['type'] = 'ws';
if ($path = data_get($protocol_settings, 'network_settings.path'))
@@ -256,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;
}
@@ -299,8 +353,10 @@ class General extends AbstractProtocol
$params['upmbps'] = $upMbps;
if ($downMbps = data_get($protocol_settings, 'bandwidth.down'))
$params['downmbps'] = $downMbps;
if ($obfsPassword = data_get($protocol_settings, 'obfs.password'))
if (data_get($protocol_settings, 'obfs.open') && ($obfsPassword = data_get($protocol_settings, 'obfs.password'))) {
$params['obfs'] = 'xplus';
$params['obfsParam'] = $obfsPassword;
}
$query = http_build_query($params);
$uri = "hysteria://{$addr}:{$server['port']}?{$query}#{$name}";
@@ -309,8 +365,8 @@ class General extends AbstractProtocol
return $uri;
}
public static function buildTuic($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
@@ -344,6 +400,10 @@ class General extends AbstractProtocol
$udpRelay = data_get($protocol_settings, 'udp_relay_mode', 'native');
$queryParams['udp-relay-mode'] = $udpRelay;
if (data_get($protocol_settings, 'tls.allow_insecure')) {
$queryParams['insecure'] = '1';
}
$query = http_build_query($queryParams);
// 构造完整URI,格式:
@@ -361,7 +421,7 @@ class General extends AbstractProtocol
public static function buildAnyTLS($password, $server)
{
@@ -372,16 +432,18 @@ class General extends AbstractProtocol
'insecure' => data_get($protocol_settings, 'tls.allow_insecure')
];
$query = http_build_query($params);
$uri = "anytls://{$password}@{$server['host']}:{$server['port']}?{$query}#{$name}";
$addr = Helper::wrapIPv6($server['host']);
$uri = "anytls://{$password}@{$addr}:{$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";
$addr = Helper::wrapIPv6($server['host']);
return "socks://{$credentials}@{$addr}:{$server['port']}#{$name}\r\n";
}
public static function buildHttp($password, $server)
+123 -11
View File
@@ -15,10 +15,12 @@ class Loon extends AbstractProtocol
Server::TYPE_TROJAN,
Server::TYPE_HYSTERIA,
Server::TYPE_VLESS,
Server::TYPE_ANYTLS,
];
protected $protocolRequirements = [
'loon.hysteria.protocol_settings.version' => [2 => '637'],
'loon.trojan.protocol_settings.tls' => [0 => '3.2.1', 1 => '3.2.1',2 => '999.9.9'],
];
public function handle()
@@ -46,6 +48,9 @@ class Loon extends AbstractProtocol
if ($item['type'] === Server::TYPE_VLESS) {
$uri .= self::buildVless($item['password'], $item);
}
if ($item['type'] === Server::TYPE_ANYTLS) {
$uri .= self::buildAnyTLS($item['password'], $item);
}
}
return response($uri)
->header('content-type', 'text/plain')
@@ -115,11 +120,10 @@ class Loon extends AbstractProtocol
];
if (data_get($protocol_settings, 'tls')) {
if (data_get($protocol_settings, 'network') === 'tcp')
$config[] = 'over-tls=true';
$config[] = 'over-tls=true';
if (data_get($protocol_settings, 'tls_settings')) {
$tls_settings = data_get($protocol_settings, 'tls_settings');
$config[] = 'skip-cert-verify=' . ($tls_settings['allow_insecure'] ? 'true' : 'false');
$config[] = 'skip-cert-verify=' . (data_get($tls_settings, 'allow_insecure') ? 'true' : 'false');
if (data_get($tls_settings, 'server_name'))
$config[] = "tls-name={$tls_settings['server_name']}";
}
@@ -150,8 +154,25 @@ class Loon extends AbstractProtocol
if (data_get($wsSettings, key: 'headers.Host'))
$config[] = "host={$wsSettings['headers']['Host']}";
break;
case 'grpc':
$config[] = 'transport=grpc';
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.headers.Host'))
$config[] = "host={$host}";
break;
}
$uri = implode(',', $config);
@@ -167,13 +188,59 @@ class Loon extends AbstractProtocol
"{$server['host']}",
"{$server['port']}",
"{$password}",
data_get($protocol_settings, 'server_name') ? "tls-name={$protocol_settings['server_name']}" : "",
'fast-open=false',
'udp=true'
];
if (!empty($protocol_settings['allow_insecure'])) {
$config[] = data_get($protocol_settings, 'allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false';
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
switch ($tlsMode) {
case 2: // Reality
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
$config[] = "tls-name={$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}";
}
$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, 'tls_settings.server_name')) {
$config[] = "tls-name={$serverName}";
}
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'tls_settings.allow_insecure', false) ? 'true' : 'false');
break;
}
switch (data_get($protocol_settings, 'network', 'tcp')) {
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;
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);
$uri = implode(',', $config);
$uri .= "\r\n";
@@ -242,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;
@@ -267,11 +352,38 @@ class Loon extends AbstractProtocol
];
if (data_get($protocol_settings, 'tls.allow_insecure'))
$config[] = "skip-cert-verify=true";
$config[] = "download-bandwidth=" . data_get($protocol_settings, 'bandwidth.download_bandwidth');
if ($down = data_get($protocol_settings, 'bandwidth.down')) {
$config[] = "download-bandwidth={$down}";
}
$config[] = "udp=true";
$config = array_filter($config);
$uri = implode(',', $config);
$uri .= "\r\n";
return $uri;
}
public static function buildAnyTLS($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$config = [
"{$server['name']}=anytls",
"{$server['host']}",
"{$server['port']}",
"{$password}",
"udp=true"
];
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);
return implode(',', $config) . "\r\n";
}
}
+188 -71
View File
@@ -2,6 +2,7 @@
namespace App\Protocols;
use App\Utils\Helper;
use App\Support\AbstractProtocol;
use App\Models\Server;
@@ -11,7 +12,11 @@ class QuantumultX extends AbstractProtocol
public $allowedProtocols = [
Server::TYPE_SHADOWSOCKS,
Server::TYPE_VMESS,
Server::TYPE_VLESS,
Server::TYPE_TROJAN,
Server::TYPE_ANYTLS,
Server::TYPE_SOCKS,
Server::TYPE_HTTP,
];
public function handle()
@@ -20,15 +25,16 @@ class QuantumultX extends AbstractProtocol
$user = $this->user;
$uri = '';
foreach ($servers as $item) {
if ($item['type'] === Server::TYPE_SHADOWSOCKS) {
$uri .= self::buildShadowsocks($item['password'], $item);
}
if ($item['type'] === Server::TYPE_VMESS) {
$uri .= self::buildVmess($item['password'], $item);
}
if ($item['type'] === Server::TYPE_TROJAN) {
$uri .= self::buildTrojan($item['password'], $item);
}
$uri .= match ($item['type']) {
Server::TYPE_SHADOWSOCKS => self::buildShadowsocks($item['password'], $item),
Server::TYPE_VMESS => self::buildVmess($item['password'], $item),
Server::TYPE_VLESS => self::buildVless($item['password'], $item),
Server::TYPE_TROJAN => self::buildTrojan($item['password'], $item),
Server::TYPE_ANYTLS => self::buildAnyTLS($item['password'], $item),
Server::TYPE_SOCKS => self::buildSocks5($item['password'], $item),
Server::TYPE_HTTP => self::buildHttp($item['password'], $item),
default => ''
};
}
return response(base64_encode($uri))
->header('content-type', 'text/plain')
@@ -39,18 +45,16 @@ class QuantumultX extends AbstractProtocol
{
$protocol_settings = $server['protocol_settings'];
$password = data_get($server, 'password', $password);
$addr = Helper::wrapIPv6($server['host']);
$config = [
"shadowsocks={$server['host']}:{$server['port']}",
"method={$protocol_settings['cipher']}",
"shadowsocks={$addr}:{$server['port']}",
"method=" . data_get($protocol_settings, 'cipher'),
"password={$password}",
'fast-open=true',
'udp-relay=true',
"tag={$server['name']}"
];
if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) {
$plugin = data_get($protocol_settings, 'plugin');
$pluginOpts = data_get($protocol_settings, 'plugin_opts', '');
// 解析插件选项
$parsedOpts = collect(explode(';', $pluginOpts))
->filter()
->mapWithKeys(function ($pair) {
@@ -61,83 +65,196 @@ class QuantumultX extends AbstractProtocol
return [trim($key) => trim($value)];
})
->all();
switch ($plugin) {
case 'obfs':
if ($plugin === 'obfs') {
if (isset($parsedOpts['obfs'])) {
$config[] = "obfs={$parsedOpts['obfs']}";
if (isset($parsedOpts['obfs-host'])) {
$config[] = "obfs-host={$parsedOpts['obfs-host']}";
}
if (isset($parsedOpts['path'])) {
$config[] = "obfs-uri={$parsedOpts['path']}";
}
break;
}
if (isset($parsedOpts['obfs-host'])) {
$config[] = "obfs-host={$parsedOpts['obfs-host']}";
}
if (isset($parsedOpts['path'])) {
$config[] = "obfs-uri={$parsedOpts['path']}";
}
}
}
$uri = implode(',', $config);
$uri .= "\r\n";
return $uri;
self::applyCommonSettings($config, $server);
return implode(',', array_filter($config)) . "\r\n";
}
public static function buildVmess($uuid, $server)
{
$protocol_settings = $server['protocol_settings'];
$addr = Helper::wrapIPv6($server['host']);
$config = [
"vmess={$server['host']}:{$server['port']}",
'method=chacha20-poly1305',
"vmess={$addr}:{$server['port']}",
"method=" . data_get($protocol_settings, 'cipher', 'auto'),
"password={$uuid}",
'fast-open=true',
'udp-relay=true',
"tag={$server['name']}"
];
if (data_get($protocol_settings, 'tls')) {
if (data_get($protocol_settings, 'network') === 'tcp')
array_push($config, 'obfs=over-tls');
if (data_get($protocol_settings, 'tls_settings')) {
if (data_get($protocol_settings, 'tls_settings.allow_insecure'))
array_push($config, 'tls-verification=' . ($protocol_settings['tls_settings']['allow_insecure'] ? 'false' : 'true'));
if (data_get($protocol_settings, 'tls_settings.server_name'))
$host = data_get($protocol_settings, 'tls_settings.server_name');
}
}
if (data_get($protocol_settings, 'network') === 'ws') {
if (data_get($protocol_settings, 'tls'))
array_push($config, 'obfs=wss');
else
array_push($config, 'obfs=ws');
if (data_get($protocol_settings, 'network_settings')) {
if (data_get($protocol_settings, 'network_settings.path'))
array_push($config, "obfs-uri={$protocol_settings['network_settings']['path']}");
if (data_get($protocol_settings, 'network_settings.headers.Host') && !isset($host))
$host = data_get($protocol_settings, 'network_settings.headers.Host');
}
}
if (isset($host)) {
array_push($config, "obfs-host={$host}");
self::applyTransportSettings($config, $protocol_settings);
self::applyCommonSettings($config, $server);
return implode(',', array_filter($config)) . "\r\n";
}
public static function buildVless($uuid, $server)
{
$protocol_settings = $server['protocol_settings'];
$addr = Helper::wrapIPv6($server['host']);
$config = [
"vless={$addr}:{$server['port']}",
'method=none',
"password={$uuid}",
];
self::applyTransportSettings($config, $protocol_settings);
if ($flow = data_get($protocol_settings, 'flow')) {
$config[] = "vless-flow={$flow}";
}
$uri = implode(',', $config);
$uri .= "\r\n";
return $uri;
self::applyCommonSettings($config, $server);
return implode(',', array_filter($config)) . "\r\n";
}
private static function applyTransportSettings(&$config, $settings, bool $nativeTls = false, ?array $tlsData = null)
{
$tlsMode = (int) data_get($settings, 'tls', 0);
$network = data_get($settings, 'network', 'tcp');
$host = null;
$isWs = $network === 'ws';
switch ($network) {
case 'ws':
$config[] = $tlsMode ? 'obfs=wss' : 'obfs=ws';
if ($path = data_get($settings, 'network_settings.path')) {
$config[] = "obfs-uri={$path}";
}
$host = data_get($settings, 'network_settings.headers.Host');
break;
case 'tcp':
$headerType = data_get($settings, 'network_settings.header.type', 'tcp');
if ($headerType === 'http') {
$config[] = 'obfs=http';
$paths = data_get($settings, 'network_settings.header.request.path', ['/']);
$config[] = 'obfs-uri=' . (is_array($paths) ? ($paths[0] ?? '/') : $paths);
$hostVal = data_get($settings, 'network_settings.header.request.headers.Host');
$host = is_array($hostVal) ? ($hostVal[0] ?? null) : $hostVal;
} elseif ($tlsMode) {
$config[] = $nativeTls ? 'over-tls=true' : 'obfs=over-tls';
}
break;
}
switch ($tlsMode) {
case 2: // Reality
$host = $host ?? data_get($settings, 'reality_settings.server_name');
if ($pubKey = data_get($settings, 'reality_settings.public_key')) {
$config[] = "reality-base64-pubkey={$pubKey}";
}
if ($shortId = data_get($settings, 'reality_settings.short_id')) {
$config[] = "reality-hex-shortid={$shortId}";
}
break;
case 1: // TLS
$resolved = $tlsData ?? (array) data_get($settings, 'tls_settings', []);
$allowInsecure = (bool) ($resolved['allow_insecure'] ?? false);
$config[] = 'tls-verification=' . ($allowInsecure ? 'false' : 'true');
$host = $host ?? ($resolved['server_name'] ?? null);
break;
}
if ($host) {
$config[] = ($nativeTls && !$isWs) ? "tls-host={$host}" : "obfs-host={$host}";
}
}
private static function applyCommonSettings(&$config, $server)
{
$config[] = 'fast-open=true';
if ($server['type'] !== Server::TYPE_HTTP) {
$config[] = 'udp-relay=true';
}
$config[] = "tag={$server['name']}";
}
public static function buildTrojan($password, $server)
{
$protocol_settings = $server['protocol_settings'];
$addr = Helper::wrapIPv6($server['host']);
$config = [
"trojan={$server['host']}:{$server['port']}",
"trojan={$addr}:{$server['port']}",
"password={$password}",
'over-tls=true',
$protocol_settings['server_name'] ? "tls-host={$protocol_settings['server_name']}" : "",
// Tips: allowInsecure=false = tls-verification=true
$protocol_settings['allow_insecure'] ? 'tls-verification=false' : 'tls-verification=true',
'fast-open=true',
'udp-relay=true',
"tag={$server['name']}"
];
$config = array_filter($config);
$uri = implode(',', $config);
$uri .= "\r\n";
return $uri;
$tlsData = [
'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);
return implode(',', array_filter($config)) . "\r\n";
}
public static function buildAnyTLS($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$addr = Helper::wrapIPv6($server['host']);
$config = [
"anytls={$addr}:{$server['port']}",
"password={$password}",
'udp-relay=true',
"tag={$server['name']}",
"over-tls=true",
];
// allow_insecure=false => tls-verification=true
// allow_insecure=true 时不写,沿用 QX 默认 false
$allowInsecure = (bool) data_get($protocol_settings, 'tls.allow_insecure', false);
if (!$allowInsecure) {
$config[] = 'tls-verification=true';
}
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$config[] = "tls-host=$serverName";
}
return implode(',', array_filter($config)) . "\r\n";
}
public static function buildSocks5($password, $server)
{
$protocol_settings = $server['protocol_settings'];
$addr = Helper::wrapIPv6($server['host']);
$config = [
"socks5={$addr}:{$server['port']}",
"username={$password}",
"password={$password}",
];
self::applyTransportSettings($config, $protocol_settings, true);
self::applyCommonSettings($config, $server);
return implode(',', array_filter($config)) . "\r\n";
}
public static function buildHttp($password, $server)
{
$protocol_settings = $server['protocol_settings'];
$addr = Helper::wrapIPv6($server['host']);
$config = [
"http={$addr}:{$server['port']}",
"username={$password}",
"password={$password}",
];
self::applyTransportSettings($config, $protocol_settings, true);
self::applyCommonSettings($config, $server);
return implode(',', array_filter($config)) . "\r\n";
}
}
+95 -13
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()
@@ -35,7 +39,7 @@ class Shadowrocket extends AbstractProtocol
$upload = round($user['u'] / (1024 * 1024 * 1024), 2);
$download = round($user['d'] / (1024 * 1024 * 1024), 2);
$totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2);
$expiredDate = date('Y-m-d', $user['expired_at']);
$expiredDate = $user['expired_at'] === null ? 'N/A' : date('Y-m-d', $user['expired_at']);
$uri .= "STATUS=🚀↑:{$upload}GB,↓:{$download}GB,TOT:{$totalTraffic}GB💡Expires:{$expiredDate}\r\n";
foreach ($servers as $item) {
if ($item['type'] === Server::TYPE_SHADOWSOCKS) {
@@ -76,7 +80,7 @@ class Shadowrocket extends AbstractProtocol
$str = str_replace(
['+', '/', '='],
['-', '_', ''],
base64_encode("{$protocol_settings['cipher']}:{$password}")
base64_encode(data_get($protocol_settings, 'cipher') . ":{$password}")
);
$addr = Helper::wrapIPv6($server['host']);
@@ -98,7 +102,7 @@ class Shadowrocket extends AbstractProtocol
'remark' => $server['name'],
'alterId' => 0
];
if ($protocol_settings['tls']) {
if (data_get($protocol_settings, 'tls')) {
$config['tls'] = 1;
if (data_get($protocol_settings, 'tls_settings')) {
if (!!data_get($protocol_settings, 'tls_settings.allow_insecure'))
@@ -128,6 +132,37 @@ class Shadowrocket extends AbstractProtocol
$config['path'] = data_get($protocol_settings, 'network_settings.serviceName');
$config['host'] = data_get($protocol_settings, 'tls_settings.server_name') ?? $server['host'];
break;
case 'httpupgrade':
$config['obfs'] = "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['obfsParam'] = $host;
}
break;
case 'h2':
$config['obfs'] = "h2";
if ($path = data_get($protocol_settings, 'network_settings.path')) {
$config['path'] = $path;
}
if ($host = data_get($protocol_settings, 'network_settings.host')) {
$config['obfsParam'] = $host[0] ?? $server['host'];
$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}";
@@ -142,7 +177,6 @@ class Shadowrocket extends AbstractProtocol
$config = [
'tfo' => 1,
'remark' => $server['name'],
'alterId' => 0
];
// 判断是否开启xtls
@@ -157,7 +191,6 @@ class Shadowrocket extends AbstractProtocol
$config['xtls'] = $xtlsMap[data_get($protocol_settings, 'flow')];
}
}
switch (data_get($protocol_settings, 'tls')) {
case 1:
$config['tls'] = 1;
@@ -211,6 +244,15 @@ class Shadowrocket extends AbstractProtocol
}
$config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'none');
break;
case 'h2':
$config['obfs'] = "h2";
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;
}
break;
case 'httpupgrade':
$config['obfs'] = "httpupgrade";
if ($path = data_get($protocol_settings, 'network_settings.path')) {
@@ -244,10 +286,24 @@ class Shadowrocket extends AbstractProtocol
{
$protocol_settings = $server['protocol_settings'];
$name = rawurlencode($server['name']);
$params['allowInsecure'] = data_get($protocol_settings, 'allow_insecure');
if ($serverName = data_get($protocol_settings, 'server_name')) {
$params['peer'] = $serverName;
$params = [];
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
switch ($tlsMode) {
case 2: // Reality
$params['security'] = 'reality';
$params['pbk'] = data_get($protocol_settings, 'reality_settings.public_key');
$params['sid'] = data_get($protocol_settings, 'reality_settings.short_id');
$params['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
break;
default: // Standard TLS
$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;
}
switch (data_get($protocol_settings, 'network')) {
case 'grpc':
$params['obfs'] = 'grpc';
@@ -258,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']);
@@ -286,7 +365,7 @@ class Shadowrocket extends AbstractProtocol
}
if (data_get($protocol_settings, 'obfs.open')) {
$params["obfs"] = "xplus";
$params["obfsParam"] = data_get($protocol_settings, 'obfs_settings.password');
$params["obfsParam"] = data_get($protocol_settings, 'obfs.password');
}
$params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure');
if (isset($server['ports']))
@@ -311,7 +390,7 @@ class Shadowrocket extends AbstractProtocol
}
$params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure');
if (isset($protocol_settings['hop_interval'])) {
$params['keepalive'] = $protocol_settings['hop_interval'];
$params['keepalive'] = data_get($protocol_settings, 'hop_interval');
}
if (isset($server['ports'])) {
$params['mport'] = $server['ports'];
@@ -341,7 +420,8 @@ class Shadowrocket extends AbstractProtocol
$params['password'] = $password;
}
$query = http_build_query($params);
$uri = "tuic://{$server['host']}:{$server['port']}?{$query}#{$name}";
$addr = Helper::wrapIPv6($server['host']);
$uri = "tuic://{$addr}:{$server['port']}?{$query}#{$name}";
$uri .= "\r\n";
return $uri;
}
@@ -355,7 +435,8 @@ class Shadowrocket extends AbstractProtocol
'insecure' => data_get($protocol_settings, 'tls.allow_insecure')
];
$query = http_build_query($params);
$uri = "anytls://{$password}@{$server['host']}:{$server['port']}?{$query}#{$name}";
$addr = Helper::wrapIPv6($server['host']);
$uri = "anytls://{$password}@{$addr}:{$server['port']}?{$query}#{$name}";
$uri .= "\r\n";
return $uri;
}
@@ -364,7 +445,8 @@ class Shadowrocket extends AbstractProtocol
{
$protocol_settings = $server['protocol_settings'];
$name = rawurlencode($server['name']);
$uri = "socks://" . base64_encode("{$password}:{$password}@{$server['host']}:{$server['port']}") . "?method=auto#{$name}";
$addr = Helper::wrapIPv6($server['host']);
$uri = 'socks://' . base64_encode("{$password}:{$password}@{$addr}:{$server['port']}") . "?method=auto#{$name}";
$uri .= "\r\n";
return $uri;
}
+300 -141
View File
@@ -5,6 +5,7 @@ use App\Utils\Helper;
use Illuminate\Support\Arr;
use App\Support\AbstractProtocol;
use App\Models\Server;
use Log;
class SingBox extends AbstractProtocol
{
@@ -36,16 +37,44 @@ class SingBox extends AbstractProtocol
],
'protocol_settings.tls' => [
'2' => '1.6.0' // Reality
],
'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' => [
'base_version' => '1.5.0',
'protocol_settings.version' => [
'2' => '1.5.0' // Hysteria 2
],
'protocol_settings.tls.ech.enabled' => [
1 => '1.5.0'
]
],
'tuic' => [
'base_version' => '1.5.0'
'base_version' => '1.5.0',
'protocol_settings.tls.ech.enabled' => [
1 => '1.5.0'
]
],
'ssh' => [
'base_version' => '1.8.0'
@@ -57,11 +86,26 @@ class SingBox extends AbstractProtocol
'base_version' => '1.5.0'
],
'anytls' => [
'base_version' => '1.12.0'
'base_version' => '1.12.0',
'protocol_settings.tls.ech.enabled' => [
1 => '1.12.0'
]
],
'socks' => [
'protocol_settings.tls_settings.ech.enabled' => [
1 => '1.5.0'
]
],
'naive' => [
'protocol_settings.tls_settings.ech.enabled' => [
1 => '1.5.0'
]
],
'http' => [
'protocol_settings.tls_settings.ech.enabled' => [
1 => '1.5.0'
]
],
'mieru' => [
'base_version' => '1.12.0'
]
]
];
@@ -133,10 +177,6 @@ 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'])) {
@@ -155,23 +195,12 @@ class SingBox extends AbstractProtocol
protected function buildRule()
{
$rules = $this->config['route']['rules'];
// Force the nodes ip to be a direct rule
// array_unshift($rules, [
// 'ip_cidr' => collect($this->servers)->pluck('host')->map(function ($host) {
// return filter_var($host, FILTER_VALIDATE_IP) ? [$host] : Helper::getIpByDomainName($host);
// })->flatten()->unique()->values(),
// 'outbound' => 'direct',
// ]);
$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
* 模板基准格式: 1.13.0+ (最新)
*/
protected function adaptConfigForVersion(): void
{
@@ -180,57 +209,190 @@ class SingBox extends AbstractProtocol
return;
}
// >= 1.11.0: 移除已废弃字段,避免 "配置已过时" 警告
if (version_compare($coreVersion, '1.11.0', '>=')) {
$this->removeDeprecatedFieldsV111();
// >= 1.13.0: 移除已删除的 block/dns 出站
if (version_compare($coreVersion, '1.13.0', '>=')) {
$this->upgradeSpecialOutboundsToActions();
}
// < 1.10.0: address 数组 → inet4_address/inet6_address
// < 1.11.0: rule action 降级为旧出站; 恢复废弃字段
if (version_compare($coreVersion, '1.11.0', '<')) {
$this->downgradeActionsToSpecialOutbounds();
$this->restoreDeprecatedInboundFields();
}
// < 1.12.0: DNS type+server → 旧 address 格式
if (version_compare($coreVersion, '1.12.0', '<')) {
$this->convertDnsServersToLegacy();
}
// < 1.10.0: tun address 数组 → inet4_address/inet6_address
if (version_compare($coreVersion, '1.10.0', '<')) {
$this->convertAddressToLegacy();
$this->convertTunAddressToLegacy();
}
}
/**
* 获取实际 sing-box 核心版本
*
* sing-box 客户端直接报核心版本,hiddify/sfm 等 wrapper 客户端
* 报的是 app 版本,需要映射到对应的 sing-box 核心版本
* 获取核心版本 (Hiddify/SFM 等映射到内核版本)
*/
private function getSingBoxCoreVersion(): ?string
{
// 优先从 UA 提取核心版本
if (!empty($this->userAgent)) {
if (preg_match('/sing-box\s+v?(\d+(?:\.\d+){0,2})/i', $this->userAgent, $matches)) {
return $matches[1];
}
}
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';
return '1.13.0';
}
/**
* sing-box >= 1.11.0: 移除废弃字段
* sing-box >= 1.13.0: block/dns 出站升级为 action
*/
private function removeDeprecatedFieldsV111(): void
private function upgradeSpecialOutboundsToActions(): void
{
$removedTags = [];
$this->config['outbounds'] = array_values(array_filter(
$this->config['outbounds'] ?? [],
function ($outbound) use (&$removedTags) {
if (in_array($outbound['type'] ?? '', ['block', 'dns'])) {
$removedTags[$outbound['tag']] = $outbound['type'];
return false;
}
return true;
}
));
if (empty($removedTags)) {
return;
}
if (isset($this->config['route']['rules'])) {
foreach ($this->config['route']['rules'] as &$rule) {
if (!isset($rule['outbound']) || !isset($removedTags[$rule['outbound']])) {
continue;
}
$type = $removedTags[$rule['outbound']];
unset($rule['outbound']);
$rule['action'] = $type === 'dns' ? 'hijack-dns' : 'reject';
}
unset($rule);
}
}
/**
* sing-box < 1.11.0: rule action 降级为旧 block/dns 出站
*/
private function downgradeActionsToSpecialOutbounds(): void
{
$needsDnsOutbound = false;
$needsBlockOutbound = false;
if (isset($this->config['route']['rules'])) {
foreach ($this->config['route']['rules'] as &$rule) {
if (!isset($rule['action'])) {
continue;
}
switch ($rule['action']) {
case 'hijack-dns':
unset($rule['action']);
$rule['outbound'] = 'dns-out';
$needsDnsOutbound = true;
break;
case 'reject':
unset($rule['action']);
$rule['outbound'] = 'block';
$needsBlockOutbound = true;
break;
}
}
unset($rule);
}
if ($needsBlockOutbound) {
$this->config['outbounds'][] = ['type' => 'block', 'tag' => 'block'];
}
if ($needsDnsOutbound) {
$this->config['outbounds'][] = ['type' => 'dns', 'tag' => 'dns-out'];
}
}
/**
* sing-box < 1.11.0: 恢复废弃的入站字段
*/
private function restoreDeprecatedInboundFields(): void
{
if (!isset($this->config['inbounds'])) {
return;
}
foreach ($this->config['inbounds'] as &$inbound) {
unset($inbound['endpoint_independent_nat']);
unset($inbound['sniff_override_destination']);
if ($inbound['type'] === 'tun') {
$inbound['endpoint_independent_nat'] = true;
}
if (!empty($inbound['sniff'])) {
$inbound['sniff_override_destination'] = true;
}
}
}
/**
* sing-box < 1.12.0: 将新 DNS server type+server 格式转换为旧 address 格式
*/
private function convertDnsServersToLegacy(): void
{
if (!isset($this->config['dns']['servers'])) {
return;
}
foreach ($this->config['dns']['servers'] as &$server) {
if (!isset($server['type'])) {
continue;
}
$type = $server['type'];
$host = $server['server'] ?? null;
switch ($type) {
case 'https':
$server['address'] = "https://{$host}/dns-query";
break;
case 'tls':
$server['address'] = "tls://{$host}";
break;
case 'tcp':
$server['address'] = "tcp://{$host}";
break;
case 'quic':
$server['address'] = "quic://{$host}";
break;
case 'udp':
$server['address'] = $host;
break;
case 'block':
$server['address'] = 'rcode://refused';
break;
case 'rcode':
$server['address'] = 'rcode://' . ($server['rcode'] ?? 'success');
unset($server['rcode']);
break;
default:
$server['address'] = $host;
break;
}
unset($server['type'], $server['server']);
}
unset($server);
}
/**
* sing-box < 1.10.0: 将 tun address 数组转换为 inet4_address/inet6_address
*/
private function convertAddressToLegacy(): void
private function convertTunAddressToLegacy(): void
{
if (!isset($this->config['inbounds'])) {
return;
@@ -289,6 +451,7 @@ class SingBox extends AbstractProtocol
];
$this->appendUtls($array['tls'], $protocol_settings);
$this->appendEch($array['tls'], data_get($protocol_settings, 'tls_settings.ech'));
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['tls']['server_name'] = $serverName;
@@ -297,42 +460,8 @@ class SingBox extends AbstractProtocol
$this->appendMultiplex($array, $protocol_settings);
$transport = match ($protocol_settings['network']) {
'tcp' => data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none' ? [
'type' => 'http',
'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])),
'host' => data_get($protocol_settings, 'network_settings.header.request.headers.Host', [])
] : null,
'ws' => array_filter([
'type' => 'ws',
'path' => data_get($protocol_settings, 'network_settings.path'),
'headers' => ($host = data_get($protocol_settings, 'network_settings.headers.Host')) ? ['Host' => $host] : null,
'max_early_data' => 2048,
'early_data_header_name' => 'Sec-WebSocket-Protocol'
]),
'grpc' => [
'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
};
if ($transport) {
$array['transport'] = array_filter($transport, fn($value) => !is_null($value));
if ($transport = $this->buildTransport($protocol_settings, $server)) {
$array['transport'] = $transport;
}
return $array;
}
@@ -347,19 +476,25 @@ class SingBox extends AbstractProtocol
"server_port" => $server['port'],
"uuid" => $password,
"packet_encoding" => "xudp",
'flow' => data_get($protocol_settings, 'flow', ''),
];
if ($flow = data_get($protocol_settings, 'flow')) {
$array['flow'] = $flow;
}
if ($protocol_settings['tls']) {
if (data_get($protocol_settings, 'tls')) {
$tlsMode = (int) data_get($protocol_settings, 'tls', 0);
$tlsConfig = [
'enabled' => true,
'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'),
'insecure' => $tlsMode === 2
? (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false)
: (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false),
];
$this->appendUtls($tlsConfig, $protocol_settings);
switch ($protocol_settings['tls']) {
switch ($tlsMode) {
case 1:
$this->appendEch($tlsConfig, data_get($protocol_settings, 'tls_settings.ech'));
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$tlsConfig['server_name'] = $serverName;
}
@@ -379,41 +514,8 @@ class SingBox extends AbstractProtocol
$this->appendMultiplex($array, $protocol_settings);
$transport = match ($protocol_settings['network']) {
'tcp' => data_get($protocol_settings, 'network_settings.header.type') == 'http' ? [
'type' => 'http',
'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/']))
] : null,
'ws' => array_filter([
'type' => 'ws',
'path' => data_get($protocol_settings, 'network_settings.path'),
'headers' => ($host = data_get($protocol_settings, 'network_settings.headers.Host')) ? ['Host' => $host] : null,
'max_early_data' => 2048,
'early_data_header_name' => 'Sec-WebSocket-Protocol'
], fn($value) => !is_null($value)),
'grpc' => [
'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
};
if ($transport) {
$array['transport'] = array_filter($transport, fn($value) => !is_null($value));
if ($transport = $this->buildTransport($protocol_settings, $server)) {
$array['transport'] = $transport;
}
return $array;
@@ -428,36 +530,37 @@ class SingBox extends AbstractProtocol
'server' => $server['host'],
'server_port' => $server['port'],
'password' => $password,
'tls' => [
'enabled' => true,
'insecure' => (bool) data_get($protocol_settings, 'allow_insecure', false),
]
];
$this->appendUtls($array['tls'], $protocol_settings);
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
$tlsConfig = ['enabled' => true];
if ($serverName = data_get($protocol_settings, 'server_name')) {
$array['tls']['server_name'] = $serverName;
switch ($tlsMode) {
case 2: // Reality
$tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false);
$tlsConfig['server_name'] = data_get($protocol_settings, 'reality_settings.server_name');
$tlsConfig['reality'] = [
'enabled' => true,
'public_key' => data_get($protocol_settings, 'reality_settings.public_key'),
'short_id' => data_get($protocol_settings, 'reality_settings.short_id'),
];
break;
default: // Standard TLS
$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')) {
$tlsConfig['server_name'] = $serverName;
}
break;
}
$this->appendUtls($tlsConfig, $protocol_settings);
$array['tls'] = $tlsConfig;
$this->appendMultiplex($array, $protocol_settings);
$transport = match (data_get($protocol_settings, 'network')) {
'grpc' => [
'type' => 'grpc',
'service_name' => data_get($protocol_settings, 'network_settings.serviceName')
],
'ws' => array_filter([
'type' => 'ws',
'path' => data_get($protocol_settings, 'network_settings.path'),
'headers' => data_get($protocol_settings, 'network_settings.headers.Host') ? ['Host' => [data_get($protocol_settings, 'network_settings.headers.Host')]] : null,
'max_early_data' => 2048,
'early_data_header_name' => 'Sec-WebSocket-Protocol'
]),
default => null
};
if ($transport) {
$array['transport'] = array_filter($transport, fn($value) => !is_null($value));
if ($transport = $this->buildTransport($protocol_settings, $server)) {
$array['transport'] = $transport;
}
return $array;
}
@@ -487,6 +590,7 @@ class SingBox extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$baseConfig['tls']['server_name'] = $serverName;
}
$this->appendEch($baseConfig['tls'], data_get($protocol_settings, 'tls.ech'));
$speedConfig = [
'up_mbps' => data_get($protocol_settings, 'bandwidth.up'),
'down_mbps' => data_get($protocol_settings, 'bandwidth.down'),
@@ -508,10 +612,9 @@ class SingBox extends AbstractProtocol
]
};
return array_merge(
$baseConfig,
$speedConfig,
$versionConfig
return array_filter(
array_merge($baseConfig, $speedConfig, $versionConfig),
fn($v) => !is_null($v)
);
}
@@ -537,6 +640,7 @@ class SingBox extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$array['tls']['server_name'] = $serverName;
}
$this->appendEch($array['tls'], data_get($protocol_settings, 'tls.ech'));
if (data_get($protocol_settings, 'version') === 4) {
$array['token'] = $password;
@@ -567,6 +671,7 @@ class SingBox extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$array['tls']['server_name'] = $serverName;
}
$this->appendEch($array['tls'], data_get($protocol_settings, 'tls.ech'));
return $array;
}
@@ -620,11 +725,53 @@ class SingBox extends AbstractProtocol
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['tls']['server_name'] = $serverName;
}
$this->appendEch($array['tls'], data_get($protocol_settings, 'tls_settings.ech'));
}
return $array;
}
protected function buildTransport(array $protocol_settings, array $server): ?array
{
$transport = match (data_get($protocol_settings, 'network')) {
'tcp' => data_get($protocol_settings, 'network_settings.header.type') === 'http' ? [
'type' => 'http',
'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])),
'host' => data_get($protocol_settings, 'network_settings.header.request.headers.Host', [])
] : null,
'ws' => [
'type' => 'ws',
'path' => data_get($protocol_settings, 'network_settings.path'),
'headers' => ($host = data_get($protocol_settings, 'network_settings.headers.Host')) ? ['Host' => $host] : null,
'max_early_data' => 0,
// 'early_data_header_name' => 'Sec-WebSocket-Protocol'
],
'grpc' => [
'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
};
if (!$transport) {
return null;
}
return array_filter($transport, fn($v) => !is_null($v));
}
protected function appendMultiplex(&$array, $protocol_settings)
{
if ($multiplex = data_get($protocol_settings, 'multiplex')) {
@@ -660,4 +807,16 @@ class SingBox extends AbstractProtocol
}
}
}
protected function appendEch(&$tlsConfig, $ech): void
{
if ($normalized = Helper::normalizeEchSettings($ech)) {
// Client outbound only needs the public ECH config, not the server's private key
$tlsConfig['ech'] = array_filter([
'enabled' => true,
'config' => data_get($normalized, 'config') ? [data_get($normalized, 'config')] : null,
'query_server_name' => data_get($normalized, 'query_server_name'),
], fn($value) => $value !== null);
}
}
}
+90 -28
View File
@@ -23,6 +23,19 @@ class Stash extends AbstractProtocol
Server::TYPE_HTTP,
];
protected $protocolRequirements = [
// Global rules applied regardless of client version (features Stash never supports)
'*' => [
'trojan' => [
'protocol_settings.tls' => [
'2' => '9999.0.0', // Trojan Reality not supported in Stash
],
],
'vmess' => [
'protocol_settings.network' => [
'httpupgrade' => '9999.0.0', // httpupgrade not supported in Stash
],
],
],
'stash' => [
'anytls' => [
'base_version' => '3.3.0' // AnyTLS 协议在3.3.0版本中添加
@@ -110,10 +123,10 @@ class Stash extends AbstractProtocol
array_push($proxy, self::buildTuic($item['password'], $item));
array_push($proxies, $item['name']);
}
// if ($item['type'] === 'anytls') {
// array_push($proxy, self::buildAnyTLS($item['password'], $item));
// array_push($proxies, $item['name']);
// }
if ($item['type'] === Server::TYPE_ANYTLS) {
array_push($proxy, self::buildAnyTLS($item['password'], $item));
array_push($proxies, $item['name']);
}
if ($item['type'] === Server::TYPE_SOCKS) {
array_push($proxy, self::buildSocks5($item['password'], $item));
array_push($proxies, $item['name']);
@@ -237,8 +250,8 @@ class Stash extends AbstractProtocol
$array['cipher'] = 'auto';
$array['udp'] = true;
$array['tls'] = data_get($protocol_settings, 'tls');
$array['skip-cert-verify'] = data_get($protocol_settings, 'tls_settings.allow_insecure');
$array['tls'] = (bool) data_get($protocol_settings, 'tls');
$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['servername'] = $serverName;
}
@@ -266,6 +279,15 @@ class Stash extends AbstractProtocol
$array['grpc-opts'] = [];
$array['grpc-opts']['grpc-service-name'] = data_get($protocol_settings, 'network_settings.serviceName');
break;
case 'h2':
$array['network'] = 'h2';
$array['tls'] = true;
$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;
default:
break;
}
@@ -297,6 +319,7 @@ class Stash extends AbstractProtocol
break;
case 2:
$array['tls'] = true;
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false);
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
$array['servername'] = $serverName;
$array['sni'] = $serverName;
@@ -335,11 +358,14 @@ 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'] = [];
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;
}
return $array;
@@ -348,13 +374,36 @@ class Stash extends AbstractProtocol
public static function buildTrojan($password, $server)
{
$protocol_settings = $server['protocol_settings'];
$array = [];
$array['name'] = $server['name'];
$array['type'] = 'trojan';
$array['server'] = $server['host'];
$array['port'] = $server['port'];
$array['password'] = $password;
$array['udp'] = true;
$array = [
'name' => $server['name'],
'type' => 'trojan',
'server' => $server['host'],
'port' => $server['port'],
'password' => $password,
'udp' => true,
];
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
switch ($tlsMode) {
case 2: // Reality
$array['tls'] = true;
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false);
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
$array['sni'] = $serverName;
}
$array['reality-opts'] = [
'public-key' => data_get($protocol_settings, 'reality_settings.public_key'),
'short-id' => data_get($protocol_settings, 'reality_settings.short_id'),
];
break;
default: // Standard TLS
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$array['sni'] = $serverName;
}
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
break;
}
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
@@ -370,11 +419,13 @@ class Stash extends AbstractProtocol
$array['ws-opts']['headers'] = ['Host' => $host];
}
break;
case 'grpc':
$array['network'] = 'grpc';
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
$array['grpc-opts']['grpc-service-name'] = $serviceName;
break;
}
if ($serverName = data_get($protocol_settings, 'server_name')) {
$array['sni'] = $serverName;
}
$array['skip-cert-verify'] = data_get($protocol_settings, 'allow_insecure');
return $array;
}
@@ -398,12 +449,18 @@ class Stash extends AbstractProtocol
$array['type'] = 'hysteria';
$array['auth-str'] = $password;
$array['protocol'] = 'udp';
$array['obfs'] = data_get($protocol_settings, 'obfs.open') ? data_get($protocol_settings, 'obfs.type') : null;
if (data_get($protocol_settings, 'obfs.open')) {
$array['obfs'] = data_get($protocol_settings, 'obfs.password');
}
break;
case 2:
$array['type'] = 'hysteria2';
$array['auth'] = $password;
$array['fast-open'] = true;
if (data_get($protocol_settings, 'obfs.open')) {
$array['obfs'] = data_get($protocol_settings, 'obfs.type', 'salamander');
$array['obfs-password'] = data_get($protocol_settings, 'obfs.password');
}
break;
}
return $array;
@@ -417,8 +474,6 @@ class Stash extends AbstractProtocol
'type' => 'tuic',
'server' => $server['host'],
'port' => $server['port'],
'uuid' => $password,
'password' => $password,
'congestion-controller' => data_get($protocol_settings, 'congestion_control', 'cubic'),
'udp-relay-mode' => data_get($protocol_settings, 'udp_relay_mode', 'native'),
'alpn' => data_get($protocol_settings, 'alpn', ['h3']),
@@ -430,6 +485,13 @@ class Stash extends AbstractProtocol
'version' => data_get($protocol_settings, 'version', 5),
];
if (data_get($protocol_settings, 'version') === 4) {
$array['token'] = $password;
} else {
$array['uuid'] = $password;
$array['password'] = $password;
}
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls.allow_insecure', false);
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$array['sni'] = $serverName;
@@ -440,15 +502,15 @@ class Stash extends AbstractProtocol
public static function buildAnyTLS($password, $server)
{
$protocol_settings = $server['protocol_settings'];
$protocol_settings = data_get($server, 'protocol_settings', []);
$array = [
'name' => $server['name'],
'type' => 'anytls',
'server' => $server['host'],
'port' => $server['port'],
'password' => $password,
'sni' => data_get($protocol_settings, 'tls_settings.server_name'),
'skip-cert-verify' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false),
'sni' => data_get($protocol_settings, 'tls.server_name'),
'skip-cert-verify' => (bool) data_get($protocol_settings, 'tls.allow_insecure', false),
'udp' => true,
];
+48 -10
View File
@@ -14,6 +14,7 @@ class Surfboard extends AbstractProtocol
Server::TYPE_SHADOWSOCKS,
Server::TYPE_VMESS,
Server::TYPE_TROJAN,
Server::TYPE_ANYTLS,
];
const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.surfboard.conf';
const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.surfboard.conf';
@@ -36,7 +37,10 @@ class Surfboard 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',
'2022-blake3-chacha20-poly1305'
])
) {
// [Proxy]
@@ -56,6 +60,10 @@ class Surfboard extends AbstractProtocol
// [Proxy Group]
$proxyGroup .= $item['name'] . ', ';
}
if ($item['type'] === Server::TYPE_ANYTLS) {
$proxies .= self::buildAnyTLS($item['password'], $item);
$proxyGroup .= $item['name'] . ', ';
}
}
$config = subscribe_template('surfboard');
@@ -74,7 +82,7 @@ class Surfboard extends AbstractProtocol
$totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2);
$unusedTraffic = $totalTraffic - $useTraffic;
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']);
$subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量: { $unusedTraffic }GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}";
$subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量{$unusedTraffic}GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}";
$config = str_replace('$subscribe_info', $subscribeInfo, $config);
return response($config, 200)
@@ -89,7 +97,7 @@ class Surfboard extends AbstractProtocol
"{$server['name']}=ss",
"{$server['host']}",
"{$server['port']}",
"encrypt-method={$protocol_settings['cipher']}",
"encrypt-method=" . data_get($protocol_settings, 'cipher'),
"password={$password}",
'tfo=true',
'udp-relay=true'
@@ -146,10 +154,12 @@ class Surfboard extends AbstractProtocol
array_push($config, 'tls=true');
if (data_get($protocol_settings, 'tls_settings')) {
$tlsSettings = data_get($protocol_settings, 'tls_settings');
if (!!data_get($tlsSettings, 'allowInsecure'))
array_push($config, 'skip-cert-verify=' . ($tlsSettings['allowInsecure'] ? 'true' : 'false'));
if (!!data_get($tlsSettings, 'serverName'))
array_push($config, "sni={$tlsSettings['serverName']}");
if (data_get($tlsSettings, 'allow_insecure')) {
array_push($config, 'skip-cert-verify=' . ($tlsSettings['allow_insecure'] ? 'true' : 'false'));
}
if ($sni = data_get($tlsSettings, 'server_name')) {
array_push($config, "sni={$sni}");
}
}
}
if (data_get($protocol_settings, 'network') === 'ws') {
@@ -176,16 +186,44 @@ class Surfboard extends AbstractProtocol
"{$server['host']}",
"{$server['port']}",
"password={$password}",
$protocol_settings['server_name'] ? "sni={$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);
$uri .= "\r\n";
return $uri;
}
public static function buildAnyTLS($password, $server)
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$config = [
"{$server['name']}=anytls",
"{$server['host']}",
"{$server['port']}",
"password={$password}",
"tfo=true",
"udp-relay=true"
];
// SNI
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);
return implode(',', $config) . "\r\n";
}
}
+11 -11
View File
@@ -95,7 +95,7 @@ class Surge extends AbstractProtocol
$totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2);
$unusedTraffic = $totalTraffic - $useTraffic;
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']);
$subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量:{ $unusedTraffic }GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}";
$subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量:{$unusedTraffic}GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}";
$config = str_replace('$subscribe_info', $subscribeInfo, $config);
return response($config, 200)
@@ -108,7 +108,7 @@ class Surge extends AbstractProtocol
{
$protocol_settings = $server['protocol_settings'];
$config = [
"{$server['name']}=ss",
"{$server['name']} = ss",
"{$server['host']}",
"{$server['port']}",
"encrypt-method={$protocol_settings['cipher']}",
@@ -152,7 +152,7 @@ class Surge extends AbstractProtocol
{
$protocol_settings = $server['protocol_settings'];
$config = [
"{$server['name']}=vmess",
"{$server['name']} = vmess",
"{$server['host']}",
"{$server['port']}",
"username={$uuid}",
@@ -191,16 +191,16 @@ class Surge extends AbstractProtocol
{
$protocol_settings = $server['protocol_settings'];
$config = [
"{$server['name']}=trojan",
"{$server['name']} = trojan",
"{$server['host']}",
"{$server['port']}",
"password={$password}",
$protocol_settings['server_name'] ? "sni={$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);
@@ -213,7 +213,7 @@ class Surge extends AbstractProtocol
{
$protocol_settings = data_get($server, 'protocol_settings', []);
$config = [
"{$server['name']}=anytls",
"{$server['name']} = anytls",
"{$server['host']}",
"{$server['port']}",
"password={$password}",
@@ -237,7 +237,7 @@ class Surge extends AbstractProtocol
if ($protocol_settings['version'] != 2)
return '';
$config = [
"{$server['name']}=hysteria2",
"{$server['name']} = hysteria2",
"{$server['host']}",
"{$server['port']}",
"password={$password}",
@@ -266,7 +266,7 @@ class Surge extends AbstractProtocol
$protocol_settings = data_get($server, 'protocol_settings', []);
$type = data_get($protocol_settings, 'tls') ? 'socks5-tls' : 'socks5';
$config = [
"{$server['name']}={$type}",
"{$server['name']} = {$type}",
"{$server['host']}",
"{$server['port']}",
"{$password}",
@@ -295,7 +295,7 @@ class Surge extends AbstractProtocol
$protocol_settings = data_get($server, 'protocol_settings', []);
$type = data_get($protocol_settings, 'tls') ? 'https' : 'http';
$config = [
"{$server['name']}={$type}",
"{$server['name']} = {$type}",
"{$server['host']}",
"{$server['port']}",
"{$password}",
+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 -5
View File
@@ -3,10 +3,8 @@
namespace App\Providers;
use App\Support\Setting;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Log;
class SettingServiceProvider extends ServiceProvider
{
@@ -30,8 +28,6 @@ class SettingServiceProvider extends ServiceProvider
*/
public function boot()
{
if ($appUrl = admin_setting('app_url')) {
URL::forceRootUrl($appUrl);
}
// App URL is forced per-request via middleware (Octane-safe).
}
}
+2 -2
View File
@@ -36,7 +36,7 @@ class LoginService
}
// 查找用户
$user = User::where('email', $email)->first();
$user = User::byEmail($email)->first();
if (!$user) {
return [false, [400, __('Incorrect email or password')]];
}
@@ -99,7 +99,7 @@ class LoginService
}
// 查找用户
$user = User::where('email', $email)->first();
$user = User::byEmail($email)->first();
if (!$user) {
return [false, [400, __('This email is not registered in the system')]];
}
+3 -3
View File
@@ -27,7 +27,7 @@ class MailLinkService
return [false, [429, __('Sending frequently, please try again later')]];
}
$user = User::where('email', $email)->first();
$user = User::byEmail($email)->first();
if (!$user) {
return [true, true]; // 成功但用户不存在,保护用户隐私
}
@@ -46,7 +46,7 @@ class MailLinkService
$this->sendMailLinkEmail($user, $link);
return [true, $link];
return [true, true];
}
/**
@@ -63,7 +63,7 @@ class MailLinkService
'subject' => __('Login to :name', [
'name' => admin_setting('app_name', 'XBoard')
]),
'template_name' => 'login',
'template_name' => 'mailLogin',
'template_value' => [
'name' => admin_setting('app_name', 'XBoard'),
'link' => $link,
+1 -2
View File
@@ -91,8 +91,7 @@ class RegisterService
}
// 检查邮箱是否存在
$email = $request->input('email');
$exist = User::where('email', $email)->first();
$exist = User::byEmail($request->input('email'))->first();
if ($exist) {
return [false, [400201, __('Email already exists')]];
}
+207
View File
@@ -0,0 +1,207 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Redis;
class DeviceStateService
{
private const PREFIX = 'user_devices:';
private const TTL = 300; // device state ttl
private const DB_THROTTLE = 10; // update db throttle
/**
* 移除 Redis key 的前缀
*/
private function removeRedisPrefix(string $key): string
{
$prefix = config('database.redis.options.prefix', '');
return $prefix ? substr($key, strlen($prefix)) : $key;
}
/**
* 批量设置设备
* 用于 HTTP /alive WebSocket report.devices
*/
public function setDevices(int $userId, int $nodeId, array $ips): void
{
$key = self::PREFIX . $userId;
$timestamp = time();
$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) {
$fields["{$nodeId}:{$ip}"] = $timestamp;
}
Redis::hMset($key, $fields);
Redis::expire($key, self::TTL);
}
$this->notifyUpdate($userId);
}
/**
* 获取某节点的所有设备数据
* 返回: {userId: [ip1, ip2, ...], ...}
*/
public function getNodeDevices(int $nodeId): array
{
$keys = Redis::keys(self::PREFIX . '*');
$prefix = "{$nodeId}:";
$result = [];
foreach ($keys as $key) {
$actualKey = $this->removeRedisPrefix($key);
$uid = (int) substr($actualKey, strlen(self::PREFIX));
$data = Redis::hgetall($actualKey);
foreach ($data as $field => $timestamp) {
if (str_starts_with($field, $prefix)) {
$ip = substr($field, strlen($prefix));
$result[$uid][] = $ip;
}
}
}
return $result;
}
/**
* 删除某节点某用户的设备
*/
public function removeNodeDevices(int $nodeId, int $userId): void
{
$key = self::PREFIX . $userId;
$prefix = "{$nodeId}:";
foreach (Redis::hkeys($key) as $field) {
if (str_starts_with($field, $prefix)) {
Redis::hdel($key, $field);
}
}
}
/**
* 清除节点所有设备数据(用于节点断开连接)
*/
public function clearAllNodeDevices(int $nodeId): array
{
$oldDevices = $this->getNodeDevices($nodeId);
$prefix = "{$nodeId}:";
foreach ($oldDevices as $userId => $ips) {
$key = self::PREFIX . $userId;
foreach (Redis::hkeys($key) as $field) {
if (str_starts_with($field, $prefix)) {
Redis::hdel($key, $field);
}
}
$this->notifyUpdate($userId);
}
return array_keys($oldDevices);
}
/**
* get user device count (deduplicated by IP, filter expired data)
*/
public function getDeviceCount(int $userId): int
{
$data = Redis::hgetall(self::PREFIX . $userId);
$now = time();
$ips = [];
foreach ($data as $field => $timestamp) {
if ($now - $timestamp <= self::TTL) {
$ips[] = substr($field, strpos($field, ':') + 1);
}
}
return count(array_unique($ips));
}
/**
* get user device count (for alivelist interface)
*/
public function getAliveList(Collection $users): array
{
if ($users->isEmpty()) {
return [];
}
$result = [];
foreach ($users as $user) {
$count = $this->getDeviceCount($user->id);
if ($count > 0) {
$result[$user->id] = $count;
}
}
return $result;
}
/**
* get devices of multiple users (for sync.devices, filter expired data)
*/
public function getUsersDevices(array $userIds): array
{
$result = [];
$now = time();
foreach ($userIds as $userId) {
$data = Redis::hgetall(self::PREFIX . $userId);
if (!empty($data)) {
$ips = [];
foreach ($data as $field => $timestamp) {
if ($now - $timestamp <= self::TTL) {
$ips[] = substr($field, strpos($field, ':') + 1);
}
}
if (!empty($ips)) {
$result[$userId] = array_unique($ips);
}
}
}
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)
*/
public function notifyUpdate(int $userId): void
{
$dbThrottleKey = "device:db_throttle:{$userId}";
// if (Redis::setnx($dbThrottleKey, 1)) {
// Redis::expire($dbThrottleKey, self::DB_THROTTLE);
User::query()
->whereKey($userId)
->update([
'online_count' => $this->getDeviceCount($userId),
'last_online_at' => now(),
]);
// }
}
}
+100 -7
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;
@@ -13,6 +14,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);
}
/**
* 获取需要发送提醒的用户总数
*/
@@ -222,15 +250,58 @@ class MailService
}
$email = $params['email'];
$subject = $params['subject'];
$params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $params['template_name'];
$templateName = $params['template_name'];
$templateValue = $params['template_value'] ?? [];
$vars = is_array($templateValue) ? ($templateValue['vars'] ?? []) : [];
$contentMode = is_array($templateValue) ? ($templateValue['content_mode'] ?? null) : null;
if (is_array($vars) && !empty($vars)) {
$subject = self::renderPlaceholders((string) $subject, $vars);
if (is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) {
$templateValue['content'] = self::renderPlaceholders($templateValue['content'], $vars);
}
}
if ($contentMode === 'text' && is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) {
$templateValue['content'] = e($templateValue['content']);
}
$params['template_value'] = $templateValue;
// Check for DB template override (cached to avoid per-email queries in bulk sends).
// Cache 'none' sentinel for templates that don't exist in DB.
$cacheKey = "mail_template:{$templateName}";
$cached = Cache::get($cacheKey);
if ($cached === null) {
$dbTemplate = MailTemplate::where('name', $templateName)->first();
Cache::put($cacheKey, $dbTemplate ?: 'none', 3600);
} else {
$dbTemplate = ($cached === 'none') ? null : $cached;
}
try {
Mail::send(
$params['template_name'],
$params['template_value'],
function ($message) use ($email, $subject) {
if ($dbTemplate) {
$renderVars = self::buildSafeVars($templateValue);
$renderedSubject = self::renderPlaceholders($dbTemplate->subject, $renderVars);
$renderedContent = self::renderPlaceholders($dbTemplate->content, $renderVars);
$subject = $renderedSubject ?: $subject;
Mail::html($renderedContent, function ($message) use ($email, $subject) {
$message->to($email)->subject($subject);
}
);
});
$params['template_name'] = 'db:' . $templateName;
} else {
$params['template_name'] = 'mail.default.' . $templateName;
Mail::send(
$params['template_name'],
$params['template_value'],
function ($message) use ($email, $subject) {
$message->to($email)->subject($subject);
}
);
}
$error = null;
} catch (\Exception $e) {
Log::error($e);
@@ -246,4 +317,26 @@ class MailService
MailLog::create($log);
return $log;
}
/**
* Build HTML-escaped vars for DB template rendering.
*/
private static function buildSafeVars(array $templateValue): array
{
$safe = [];
foreach ($templateValue as $key => $value) {
if (is_scalar($value)) {
$safe[$key] = e((string) $value);
}
}
// 'content' may be pre-escaped text or admin-authored HTML.
// For text mode, apply nl2br so line breaks survive in DB templates
// (Blade templates handle this with {!! nl2br($content) !!}).
if (isset($templateValue['content'])) {
$content = (string) $templateValue['content'];
$contentMode = $templateValue['content_mode'] ?? null;
$safe['content'] = ($contentMode === 'text') ? nl2br($content) : $content;
}
return $safe;
}
}
+96 -3
View File
@@ -13,25 +13,55 @@ class NodeRegistry
/** @var array<int, TcpConnection> nodeId → connection */
private static array $connections = [];
/** @var array<int, TcpConnection> machineId → connection */
private static array $machineConnections = [];
public static function add(int $nodeId, TcpConnection $conn): void
{
// Close existing connection for this node (if reconnecting)
if (isset(self::$connections[$nodeId])) {
if (isset(self::$connections[$nodeId]) && self::$connections[$nodeId] !== $conn) {
self::$connections[$nodeId]->close();
}
self::$connections[$nodeId] = $conn;
}
public static function remove(int $nodeId): void
public static function addMachine(int $machineId, TcpConnection $conn): void
{
if (isset(self::$machineConnections[$machineId]) && self::$machineConnections[$machineId] !== $conn) {
self::$machineConnections[$machineId]->close();
}
self::$machineConnections[$machineId] = $conn;
}
/**
* Remove a node mapping only if it still points to the given connection.
* Passing null removes unconditionally (backward compat for single-node mode).
*/
public static function remove(int $nodeId, ?TcpConnection $conn = null): void
{
if ($conn !== null && isset(self::$connections[$nodeId]) && self::$connections[$nodeId] !== $conn) {
return; // already replaced by a newer connection
}
unset(self::$connections[$nodeId]);
}
public static function removeMachine(int $machineId, ?TcpConnection $conn = null): void
{
if ($conn !== null && isset(self::$machineConnections[$machineId]) && self::$machineConnections[$machineId] !== $conn) {
return;
}
unset(self::$machineConnections[$machineId]);
}
public static function get(int $nodeId): ?TcpConnection
{
return self::$connections[$nodeId] ?? null;
}
public static function getMachine(int $machineId): ?TcpConnection
{
return self::$machineConnections[$machineId] ?? null;
}
/**
* Send a JSON message to a specific node.
*/
@@ -42,6 +72,55 @@ class NodeRegistry
return false;
}
// Machine-mode connections multiplex multiple node IDs through the same
// socket, so node-scoped events must carry node_id for the client mux.
if (!empty($conn->machineNodeIds) && $event !== 'sync.nodes' && !array_key_exists('node_id', $data)) {
$data['node_id'] = $nodeId;
}
$payload = json_encode([
'event' => $event,
'data' => $data,
'timestamp' => time(),
]);
$conn->send($payload);
return true;
}
/**
* Update in-memory registry when a machine's node set changes.
* Called from the WS process when a sync.nodes event is dispatched.
*/
public static function refreshMachineNodes(int $machineId, array $newNodeIds): void
{
$conn = self::getMachine($machineId);
if (!$conn) {
return;
}
$oldNodeIds = $conn->machineNodeIds ?? [];
// Remove nodes no longer on this machine
foreach (array_diff($oldNodeIds, $newNodeIds) as $removedId) {
self::remove($removedId, $conn);
}
// Add newly assigned nodes (via add() to close any stale standalone connection)
foreach ($newNodeIds as $nodeId) {
self::add($nodeId, $conn);
}
$conn->machineNodeIds = $newNodeIds;
}
public static function sendMachine(int $machineId, string $event, array $data): bool
{
$conn = self::getMachine($machineId);
if (!$conn) {
return false;
}
$payload = json_encode([
'event' => $event,
'data' => $data,
@@ -74,4 +153,18 @@ class NodeRegistry
{
return count(self::$connections);
}
/**
* @return int[]
*/
public static function getConnectedMachineIds(): array
{
return array_keys(self::$machineConnections);
}
public static function machineCount(): int
{
return count(self::$machineConnections);
}
}
+44 -3
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Server;
use App\Models\ServerMachine;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
@@ -13,7 +14,7 @@ class NodeSyncService
/**
* Check if node has active WS connection
*/
private static function isNodeOnline(int $nodeId): bool
public static function isNodeOnline(int $nodeId): bool
{
return (bool) Cache::get("node_ws_alive:{$nodeId}");
}
@@ -30,7 +31,6 @@ class NodeSyncService
if (!$node)
return;
self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]);
}
@@ -122,10 +122,32 @@ class NodeSyncService
self::push($nodeId, 'sync.users', ['users' => $users]);
}
/**
* Notify machine that its node set has changed.
* Always publishes via Redis so the WS process can update its in-memory registry.
*/
public static function notifyMachineNodesChanged(int $machineId): void
{
$machine = ServerMachine::find($machineId);
$nodeList = [];
if ($machine) {
$nodes = ServerService::getMachineNodes($machine);
$nodeList = $nodes->map(fn($n) => [
'id' => $n->id,
'type' => $n->type,
'name' => $n->name,
])->values()->toArray();
}
// Always publish via Redis so the WS process can update its in-memory registry
self::pushMachine($machineId, 'sync.nodes', ['nodes' => $nodeList]);
}
/**
* Publish a push command to Redis picked up by the Workerman WS server
*/
private static function push(int $nodeId, string $event, array $data): void
public static function push(int $nodeId, string $event, array $data): void
{
try {
Redis::publish('node:push', json_encode([
@@ -140,4 +162,23 @@ class NodeSyncService
]);
}
}
/**
* Publish a machine-level push command to Redis picked up by the Workerman WS server
*/
public static function pushMachine(int $machineId, string $event, array $data): void
{
try {
Redis::publish('node:push', json_encode([
'machine_id' => $machineId,
'event' => $event,
'data' => $data,
]));
} catch (\Throwable $e) {
Log::warning("[NodePush] Redis machine publish failed: {$e->getMessage()}", [
'machine_id' => $machineId,
'event' => $event,
]);
}
}
}
+18 -9
View File
@@ -8,7 +8,6 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -219,6 +218,20 @@ class PluginManager
return $defaultValues;
}
/**
* 获取 Migrator 实例并确保迁移仓库存在
*/
protected function getMigrator(): \Illuminate\Database\Migrations\Migrator
{
$migrator = app('migrator');
if (!$migrator->repositoryExists()) {
$migrator->getRepository()->createRepository();
}
return $migrator;
}
/**
* 运行插件数据库迁移
*/
@@ -227,10 +240,8 @@ class PluginManager
$migrationsPath = $this->getPluginPath($pluginCode) . '/database/migrations';
if (File::exists($migrationsPath)) {
Artisan::call('migrate', [
'--path' => "plugins/" . Str::studly($pluginCode) . "/database/migrations",
'--force' => true
]);
$migrator = $this->getMigrator();
$migrator->run([$migrationsPath]);
}
}
@@ -242,10 +253,8 @@ class PluginManager
$migrationsPath = $this->getPluginPath($pluginCode) . '/database/migrations';
if (File::exists($migrationsPath)) {
Artisan::call('migrate:rollback', [
'--path' => "plugins/" . Str::studly($pluginCode) . "/database/migrations",
'--force' => true
]);
$migrator = $this->getMigrator();
$migrator->rollback([$migrationsPath]);
}
}
+146 -8
View File
@@ -3,10 +3,13 @@
namespace App\Services;
use App\Models\Server;
use App\Models\ServerMachine;
use App\Models\ServerRoute;
use App\Models\User;
use App\Services\Plugin\HookManager;
use App\Utils\CacheKey;
use App\Utils\Helper;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Collection;
class ServerService
@@ -33,6 +36,17 @@ class ServerService
]);
}
/**
* 获取机器下所有已启用节点
*/
public static function getMachineNodes(ServerMachine $machine): Collection
{
return Server::where('machine_id', $machine->id)
->where('enabled', true)
->orderBy('sort', 'ASC')
->get();
}
/**
* 获取指定用户可用的服务器列表
* @param User $user
@@ -42,6 +56,11 @@ class ServerService
{
$servers = Server::whereJsonContains('group_ids', (string) $user->group_id)
->where('show', true)
->where(function ($query) {
$query->whereNull('transfer_enable')
->orWhere('transfer_enable', 0)
->orWhereRaw('u + d < transfer_enable');
})
->orderBy('sort', 'ASC')
->get()
->append(['last_check_at', 'last_push_at', 'online', 'is_online', 'available_status', 'cache_key', 'server_key']);
@@ -70,8 +89,12 @@ class ServerService
*/
public static function getAvailableUsers(Server $node)
{
$groupIds = $node->group_ids ?? [];
if (empty($groupIds)) {
return collect();
}
$users = User::toBase()
->whereIn('group_id', $node->group_ids)
->whereIn('group_id', $groupIds)
->whereRaw('u + d < transfer_enable')
->where(function ($query) {
$query->where('expired_at', '>=', time())
@@ -95,6 +118,100 @@ class ServerService
return $routes;
}
/**
* 处理节点流量数据汇报
*/
public static function processTraffic(Server $node, array $traffic): void
{
$data = array_filter($traffic, fn($item) =>
is_array($item) && count($item) === 2
&& is_numeric($item[0]) && is_numeric($item[1])
);
if (empty($data)) {
return;
}
$nodeType = strtoupper($node->type);
$nodeId = $node->id;
Cache::put(CacheKey::get("SERVER_{$nodeType}_ONLINE_USER", $nodeId), count($data), 3600);
Cache::put(CacheKey::get("SERVER_{$nodeType}_LAST_PUSH_AT", $nodeId), time(), 3600);
(new UserService())->trafficFetch($node, $node->type, $data);
}
/**
* 处理节点在线设备汇报
*/
public static function processAlive(int $nodeId, array $alive): void
{
$service = app(DeviceStateService::class);
foreach ($alive as $uid => $ips) {
$service->setDevices((int) $uid, $nodeId, (array) $ips);
}
}
/**
* 处理节点连接数汇报
*/
public static function processOnline(Server $node, array $online): void
{
$cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3);
$nodeType = $node->type;
$nodeId = $node->id;
foreach ($online as $uid => $conn) {
$cacheKey = CacheKey::get("USER_ONLINE_CONN_{$nodeType}_{$nodeId}", $uid);
Cache::put($cacheKey, (int) $conn, $cacheTime);
}
}
/**
* 处理节点负载状态汇报
*/
public static function processStatus(Server $node, array $status): void
{
$nodeType = strtoupper($node->type);
$nodeId = $node->id;
$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_{$nodeType}_LOAD_STATUS", $nodeId) => $statusData,
CacheKey::get("SERVER_{$nodeType}_LAST_LOAD_AT", $nodeId) => now()->timestamp,
], $cacheTime);
}
/**
* 标记节点心跳
*/
public static function touchNode(Server $node): void
{
Cache::put(
CacheKey::get('SERVER_' . strtoupper($node->type) . '_LAST_CHECK_AT', $node->id),
time(),
3600
);
}
/**
* Update node metrics and load status
*/
@@ -124,8 +241,8 @@ class ServerService
'kernel_status' => (bool) ($metrics['kernel_status'] ?? false),
];
\Illuminate\Support\Facades\Cache::put(
\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId),
Cache::put(
CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId),
$metricsData,
$cacheTime
);
@@ -161,18 +278,28 @@ class ServerService
'vmess' => [
...$baseConfig,
'tls' => (int) $protocolSettings['tls'],
'tls_settings' => $protocolSettings['tls_settings'],
'multiplex' => data_get($protocolSettings, 'multiplex'),
],
'trojan' => [
...$baseConfig,
'host' => $host,
'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 => $protocolSettings['tls_settings'],
},
],
'vless' => [
...$baseConfig,
'tls' => (int) $protocolSettings['tls'],
'flow' => $protocolSettings['flow'],
'decryption' => match (data_get($protocolSettings, 'encryption.enabled')) {
true => data_get($protocolSettings, 'encryption.decryption'),
default => null,
},
'tls_settings' => match ((int) $protocolSettings['tls']) {
2 => $protocolSettings['reality_settings'],
default => $protocolSettings['tls_settings'],
@@ -185,6 +312,7 @@ class ServerService
'version' => (int) $protocolSettings['version'],
'host' => $host,
'server_name' => $protocolSettings['tls']['server_name'],
'tls_settings' => $protocolSettings['tls'],
'up_mbps' => (int) $protocolSettings['bandwidth']['up'],
'down_mbps' => (int) $protocolSettings['bandwidth']['down'],
...match ((int) $protocolSettings['version']) {
@@ -202,7 +330,7 @@ class ServerService
'server_port' => (int) $serverPort,
'server_name' => $protocolSettings['tls']['server_name'],
'congestion_control' => $protocolSettings['congestion_control'],
'tls_settings' => data_get($protocolSettings, 'tls_settings'),
'tls_settings' => $protocolSettings['tls'],
'auth_timeout' => '3s',
'zero_rtt_handshake' => false,
'heartbeat' => '3s',
@@ -211,11 +339,14 @@ class ServerService
...$baseConfig,
'server_port' => (int) $serverPort,
'server_name' => $protocolSettings['tls']['server_name'],
'tls_settings' => $protocolSettings['tls'],
'padding_scheme' => $protocolSettings['padding_scheme'],
],
'socks' => [
...$baseConfig,
'server_port' => (int) $serverPort,
'tls' => (int) data_get($protocolSettings, 'tls', 0),
'tls_settings' => data_get($protocolSettings, 'tls_settings'),
],
'naive' => [
...$baseConfig,
@@ -234,7 +365,6 @@ class ServerService
'server_port' => (int) $serverPort,
'transport' => data_get($protocolSettings, 'transport', 'TCP'),
'traffic_pattern' => $protocolSettings['traffic_pattern'],
// 'multiplex' => data_get($protocolSettings, 'multiplex'),
],
default => [],
};
@@ -252,7 +382,15 @@ class ServerService
}
if (!empty($node['cert_config'])) {
$response['cert_config'] = $node['cert_config'];
$certConfig = $node['cert_config'];
// Normalize: accept both "mode" and "cert_mode" from the database
if (isset($certConfig['mode']) && !isset($certConfig['cert_mode'])) {
$certConfig['cert_mode'] = $certConfig['mode'];
unset($certConfig['mode']);
}
if (data_get($certConfig, 'cert_mode') !== 'none') {
$response['cert_config'] = $certConfig;
}
}
return $response;
@@ -264,7 +402,7 @@ class ServerService
* @param string $serverType
* @return Server|null
*/
public static function getServer($serverId, ?string $serverType)
public static function getServer($serverId, ?string $serverType = null): Server | null
{
return Server::query()
->when($serverType, function ($query) use ($serverType) {
+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('工单创建失败');
-123
View File
@@ -1,123 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class UserOnlineService
{
/**
* 缓存相关常量
*/
private const CACHE_PREFIX = 'ALIVE_IP_USER_';
/**
* 获取所有限制设备用户的在线数量
*/
public function getAliveList(Collection $deviceLimitUsers): array
{
if ($deviceLimitUsers->isEmpty()) {
return [];
}
$cacheKeys = $deviceLimitUsers->pluck('id')
->map(fn(int $id): string => self::CACHE_PREFIX . $id)
->all();
return collect(cache()->many($cacheKeys))
->filter()
->map(fn(array $data): ?int => $data['alive_ip'] ?? null)
->filter()
->mapWithKeys(fn(int $count, string $key): array => [
(int) Str::after($key, self::CACHE_PREFIX) => $count
])
->all();
}
/**
* 获取指定用户的在线设备信息
*/
public static function getUserDevices(int $userId): array
{
$data = cache()->get(self::CACHE_PREFIX . $userId, []);
if (empty($data)) {
return ['total_count' => 0, 'devices' => []];
}
$devices = collect($data)
->filter(fn(mixed $item): bool => is_array($item) && isset($item['aliveips']))
->flatMap(function (array $nodeData, string $nodeKey): array {
return collect($nodeData['aliveips'])
->mapWithKeys(function (string $ipNodeId) use ($nodeData, $nodeKey): array {
$ip = Str::before($ipNodeId, '_');
return [
$ip => [
'ip' => $ip,
'last_seen' => $nodeData['lastupdateAt'],
'node_type' => Str::before($nodeKey, (string) $nodeData['lastupdateAt'])
]
];
})
->all();
})
->values()
->all();
return [
'total_count' => $data['alive_ip'] ?? 0,
'devices' => $devices
];
}
/**
* 批量获取用户在线设备数
*/
public function getOnlineCounts(array $userIds): array
{
$cacheKeys = collect($userIds)
->map(fn(int $id): string => self::CACHE_PREFIX . $id)
->all();
return collect(cache()->many($cacheKeys))
->filter()
->map(fn(array $data): int => $data['alive_ip'] ?? 0)
->all();
}
/**
* 获取用户在线设备数
*/
public function getOnlineCount(int $userId): int
{
$data = cache()->get(self::CACHE_PREFIX . $userId, []);
return $data['alive_ip'] ?? 0;
}
/**
* 计算在线设备数量
*/
public static function calculateDeviceCount(array $ipsArray): int
{
$mode = (int) admin_setting('device_limit_mode', 0);
return match ($mode) {
1 => collect($ipsArray)
->filter(fn(mixed $data): bool => is_array($data) && isset($data['aliveips']))
->flatMap(
fn(array $data): array => collect($data['aliveips'])
->map(fn(string $ipNodeId): string => Str::before($ipNodeId, '_'))
->unique()
->all()
)
->unique()
->count(),
0 => collect($ipsArray)
->filter(fn(mixed $data): bool => is_array($data) && isset($data['aliveips']))
->sum(fn(array $data): int => count($data['aliveips'])),
default => throw new \InvalidArgumentException("Invalid device limit mode: $mode"),
};
}
}
+12 -1
View File
@@ -26,6 +26,11 @@ abstract class AbstractProtocol
*/
protected $clientVersion;
/**
* @var string|null 原始 User-Agent
*/
protected $userAgent;
/**
* @var array 协议标识
*/
@@ -48,13 +53,15 @@ abstract class AbstractProtocol
* @param array $servers 服务器信息
* @param string|null $clientName 客户端名称
* @param string|null $clientVersion 客户端版本
* @param string|null $userAgent 原始 User-Agent
*/
public function __construct($user, $servers, $clientName = null, $clientVersion = null)
public function __construct($user, $servers, $clientName = null, $clientVersion = null, $userAgent = null)
{
$this->user = $user;
$this->servers = $servers;
$this->clientName = $clientName;
$this->clientVersion = $clientVersion;
$this->userAgent = $userAgent;
$this->protocolRequirements = $this->normalizeProtocolRequirements($this->protocolRequirements);
$this->servers = HookManager::filter('protocol.servers.filtered', $this->filterServersByVersion());
}
@@ -144,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;
+58
View File
@@ -86,6 +86,7 @@ class Helper
case 'md5': return md5($password) === $hash;
case 'sha256': return hash('sha256', $password) === $hash;
case 'md5salt': return md5($password . $salt) === $hash;
case 'sha256salt': return hash('sha256', $password . $salt) === $hash;
default: return password_verify($password, $hash);
}
}
@@ -206,6 +207,53 @@ class Helper
return Arr::random($fingerprints);
}
public static function normalizeEchSettings($ech = null): ?array
{
if (!is_array($ech) && !is_object($ech)) {
return null;
}
if (!data_get($ech, 'enabled')) {
return null;
}
return array_filter([
'enabled' => true,
'config' => self::trimToNull(data_get($ech, 'config')),
'query_server_name' => self::trimToNull(data_get($ech, 'query_server_name')),
'key' => self::trimToNull(data_get($ech, 'key')),
'key_path' => self::trimToNull(data_get($ech, 'key_path')),
'config_path' => self::trimToNull(data_get($ech, 'config_path')),
], static fn($value) => $value !== null);
}
public static function toMihomoEchConfig(?string $config): ?string
{
$config = self::trimToNull($config);
if (!$config) {
return null;
}
if (str_starts_with($config, '-----BEGIN')) {
if (preg_match('/-----BEGIN ECH CONFIGS-----\s*(.*?)\s*-----END ECH CONFIGS-----/s', $config, $matches)) {
return preg_replace('/\s+/', '', $matches[1]);
}
return null;
}
return preg_replace('/\s+/', '', $config);
}
public static function trimToNull($value): ?string
{
if (!is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
public static function encodeURIComponent($str) {
$revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')');
return strtr(rawurlencode($str), $revert);
@@ -229,4 +277,14 @@ class Helper
{
return $transfer_enable / 1073741824;
}
/**
* 转义 Telegram Markdown 特殊字符
* @param string $text
* @return string
*/
public static function escapeMarkdown(string $text): string
{
return str_replace(['_', '*', '`', '['], ['\_', '\*', '\`', '\['], $text);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\WebSocket;
use App\Models\Server;
use App\Services\DeviceStateService;
use App\Services\NodeRegistry;
use App\Services\ServerService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Workerman\Connection\TcpConnection;
class NodeEventHandlers
{
/**
* Handle pong heartbeat
*/
public static function handlePong(TcpConnection $conn, int $nodeId, array $data = []): void
{
Cache::put("node_ws_alive:{$nodeId}", true, 86400);
}
/**
* Handle node status update
*/
public static function handleNodeStatus(TcpConnection $conn, int $nodeId, array $data): void
{
$node = Server::find($nodeId);
if (!$node) return;
$nodeType = strtoupper($node->type);
Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600);
ServerService::updateMetrics($node, $data);
Log::debug("[WS] Node#{$nodeId} status updated");
}
/**
* Handle device report from node
*
* 数据格式: {"event": "report.devices", "data": {userId: [ip1, ip2, ...], ...}}
*/
public static function handleDeviceReport(TcpConnection $conn, int $nodeId, array $data): void
{
$service = app(DeviceStateService::class);
if (isset($data['devices']) && is_array($data['devices'])) {
$data = $data['devices'];
}
// Get old data
$oldDevices = $service->getNodeDevices($nodeId);
// Calculate diff
$removedUsers = array_diff_key($oldDevices, $data);
$newDevices = [];
foreach ($data as $userId => $ips) {
if (is_numeric($userId) && is_array($ips)) {
$newDevices[(int) $userId] = $ips;
}
}
// Handle removed users
foreach ($removedUsers as $userId => $ips) {
$service->removeNodeDevices($nodeId, $userId);
$service->notifyUpdate($userId);
}
// Handle new/updated users
foreach ($newDevices as $userId => $ips) {
$service->setDevices($userId, $nodeId, $ips);
}
// Mark for push
Redis::sadd('device:push_pending_nodes', $nodeId);
Log::debug("[WS] Node#{$nodeId} synced " . count($newDevices) . " users, removed " . count($removedUsers));
}
/**
* Handle device state request from node
*/
public static function handleDeviceRequest(TcpConnection $conn, int $nodeId, array $data = []): void
{
$node = Server::find($nodeId);
if (!$node) return;
$users = ServerService::getAvailableUsers($node);
$userIds = $users->pluck('id')->toArray();
$service = app(DeviceStateService::class);
$devices = $service->getUsersDevices($userIds);
NodeRegistry::send($nodeId, 'sync.devices', [
'users' => $devices,
]);
Log::debug("[WS] Node#{$nodeId} requested devices, sent " . count($devices) . " users");
}
/**
* Push device state to node
*/
public static function pushDeviceStateToNode(int $nodeId, DeviceStateService $service): void
{
$node = Server::find($nodeId);
if (!$node) return;
$users = ServerService::getAvailableUsers($node);
$userIds = $users->pluck('id')->toArray();
$devices = $service->getUsersDevices($userIds);
NodeRegistry::send($nodeId, 'sync.devices', [
'users' => $devices
]);
Log::debug("[WS] Pushed device state to node#{$nodeId}: " . count($devices) . " users");
}
/**
* Push full config + users to newly connected node
*/
public static function pushFullSync(TcpConnection $conn, Server $node): void
{
$nodeId = (int) $node->id;
// Push config
$config = ServerService::buildNodeConfig($node);
NodeRegistry::send($nodeId, 'sync.config', [
'config' => $config,
]);
// Push users
$users = ServerService::getAvailableUsers($node)->toArray();
NodeRegistry::send($nodeId, 'sync.users', [
'users' => $users,
]);
Log::info("[WS] Full sync pushed to node#{$nodeId}", [
'users' => count($users),
]);
}
}
+398
View File
@@ -0,0 +1,398 @@
<?php
namespace App\WebSocket;
use App\Models\Server;
use App\Models\ServerMachine;
use App\Services\DeviceStateService;
use App\Services\NodeRegistry;
use App\Services\ServerService;
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 NodeWorker
{
private const AUTH_TIMEOUT = 10;
private const PING_INTERVAL = 55;
private Worker $worker;
private array $handlers = [
'pong' => [NodeEventHandlers::class, 'handlePong'],
'node.status' => [NodeEventHandlers::class, 'handleNodeStatus'],
'report.devices' => [NodeEventHandlers::class, 'handleDeviceReport'],
'request.devices' => [NodeEventHandlers::class, 'handleDeviceRequest'],
];
public function __construct(string $host, int $port)
{
$this->worker = new Worker("websocket://{$host}:{$port}");
$this->worker->count = 1;
$this->worker->name = 'xboard-ws-server';
}
public function run(): void
{
$this->setupLogging();
$this->setupCallbacks();
Worker::runAll();
}
private function setupLogging(): void
{
$logPath = storage_path('logs');
if (!is_dir($logPath)) {
mkdir($logPath, 0777, true);
}
Worker::$logFile = $logPath . '/xboard-ws-server.log';
Worker::$pidFile = $logPath . '/xboard-ws-server.pid';
}
private function setupCallbacks(): void
{
$this->worker->onWorkerStart = [$this, 'onWorkerStart'];
$this->worker->onConnect = [$this, 'onConnect'];
$this->worker->onWebSocketConnect = [$this, 'onWebSocketConnect'];
$this->worker->onMessage = [$this, 'onMessage'];
$this->worker->onClose = [$this, 'onClose'];
}
public function onWorkerStart(Worker $worker): void
{
Log::info("[WS] Worker started, pid={$worker->id}");
$this->subscribeRedis();
$this->setupTimers();
}
private function setupTimers(): void
{
Timer::add(self::PING_INTERVAL, function () {
$seen = [];
foreach (NodeRegistry::getConnectedNodeIds() as $nodeId) {
$conn = NodeRegistry::get($nodeId);
if ($conn) {
$oid = spl_object_id($conn);
if (!isset($seen[$oid])) {
$seen[$oid] = true;
$conn->send(json_encode(['event' => 'ping']));
}
}
}
foreach (NodeRegistry::getConnectedMachineIds() as $machineId) {
$conn = NodeRegistry::getMachine($machineId);
if ($conn) {
$oid = spl_object_id($conn);
if (!isset($seen[$oid])) {
$seen[$oid] = true;
$conn->send(json_encode(['event' => 'ping']));
}
}
}
});
Timer::add(10, function () {
$pendingNodeIds = Redis::spop('device:push_pending_nodes', 100);
if (empty($pendingNodeIds)) {
return;
}
$service = app(DeviceStateService::class);
foreach ($pendingNodeIds as $nodeId) {
$nodeId = (int) $nodeId;
if (NodeRegistry::get($nodeId) !== null) {
NodeEventHandlers::pushDeviceStateToNode($nodeId, $service);
}
}
});
}
public function onConnect(TcpConnection $conn): void
{
$conn->authTimer = Timer::add(self::AUTH_TIMEOUT, function () use ($conn) {
if (empty($conn->nodeId) && empty($conn->machineNodeIds)) {
$conn->close(json_encode([
'event' => 'error',
'data' => ['message' => 'auth timeout'],
]));
}
}, [], false);
}
public function onWebSocketConnect(TcpConnection $conn, $httpMessage): void
{
$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);
if (isset($conn->authTimer)) {
Timer::del($conn->authTimer);
}
// 判断认证模式
if (!empty($params['machine_id'])) {
$this->authenticateMachine($conn, $params);
} else {
$this->authenticateNode($conn, $params);
}
}
/**
* 旧模式:单节点认证
*/
private function authenticateNode(TcpConnection $conn, array $params): void
{
$token = $params['token'] ?? '';
$nodeId = (int) ($params['node_id'] ?? 0);
$serverToken = admin_setting('server_token', '');
if ($token === '' || $serverToken === '' || !hash_equals($serverToken, $token)) {
$conn->close(json_encode([
'event' => 'error',
'data' => ['message' => 'invalid token'],
]));
return;
}
$node = ServerService::getServer($nodeId, null);
if (!$node) {
$conn->close(json_encode([
'event' => 'error',
'data' => ['message' => 'node not found'],
]));
return;
}
$conn->nodeId = $nodeId;
NodeRegistry::add($nodeId, $conn);
Cache::put("node_ws_alive:{$nodeId}", true, 86400);
app(DeviceStateService::class)->clearAllNodeDevices($nodeId);
Log::debug("[WS] Node#{$nodeId} connected", [
'remote' => $conn->getRemoteIp(),
'total' => NodeRegistry::count(),
]);
$conn->send(json_encode([
'event' => 'auth.success',
'data' => ['node_id' => $nodeId],
]));
NodeEventHandlers::pushFullSync($conn, $node);
}
/**
* 新模式:机器认证,自动注册该机器下所有已启用节点
*/
private function authenticateMachine(TcpConnection $conn, array $params): void
{
$machineId = (int) ($params['machine_id'] ?? 0);
$token = $params['token'] ?? '';
$machine = ServerMachine::where('id', $machineId)
->where('token', $token)
->first();
if (!$machine || !$machine->is_active) {
$conn->close(json_encode([
'event' => 'error',
'data' => ['message' => 'invalid machine credentials'],
]));
return;
}
$nodes = ServerService::getMachineNodes($machine);
$machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly();
NodeRegistry::addMachine($machineId, $conn);
// 把同一个连接注册到该机器下所有节点
$nodeIds = [];
$deviceService = app(DeviceStateService::class);
foreach ($nodes as $node) {
NodeRegistry::add($node->id, $conn);
Cache::put("node_ws_alive:{$node->id}", true, 86400);
$deviceService->clearAllNodeDevices($node->id);
$nodeIds[] = $node->id;
}
// 连接上记录所属机器和节点列表
$conn->machineId = $machineId;
$conn->machineNodeIds = $nodeIds;
Log::debug("[WS] Machine#{$machineId} connected, nodes: " . implode(',', $nodeIds), [
'remote' => $conn->getRemoteIp(),
'total' => NodeRegistry::count(),
'machines' => NodeRegistry::machineCount(),
]);
$conn->send(json_encode([
'event' => 'auth.success',
'data' => [
'machine_id' => $machineId,
'node_ids' => $nodeIds,
],
]));
// 为每个节点推送完整同步
foreach ($nodes as $node) {
NodeEventHandlers::pushFullSync($conn, $node);
}
}
public function onMessage(TcpConnection $conn, $data): void
{
$msg = json_decode($data, true);
if (!is_array($msg)) {
return;
}
$event = $msg['event'] ?? '';
// 机器连接:从消息中读取 node_id 来分派到具体节点
if (!empty($conn->machineNodeIds)) {
if ($event === 'pong') {
foreach ($conn->machineNodeIds as $nid) {
Cache::put("node_ws_alive:{$nid}", true, 86400);
}
return;
}
$nodeId = (int) ($msg['data']['node_id'] ?? 0);
if ($nodeId <= 0 || !in_array($nodeId, $conn->machineNodeIds, true)) {
return;
}
if (isset($this->handlers[$event])) {
$handler = $this->handlers[$event];
$handler($conn, $nodeId, $msg['data'] ?? []);
}
return;
}
// 旧模式:单节点
$nodeId = $conn->nodeId ?? null;
if (isset($this->handlers[$event]) && $nodeId) {
$handler = $this->handlers[$event];
$handler($conn, $nodeId, $msg['data'] ?? []);
}
}
public function onClose(TcpConnection $conn): void
{
$service = app(DeviceStateService::class);
// 机器模式:清理所有关联节点
if (!empty($conn->machineNodeIds)) {
$machineId = $conn->machineId ?? 'unknown';
foreach ($conn->machineNodeIds as $nodeId) {
NodeRegistry::remove($nodeId, $conn);
Cache::forget("node_ws_alive:{$nodeId}");
$affectedUserIds = $service->clearAllNodeDevices($nodeId);
foreach ($affectedUserIds as $userId) {
$service->notifyUpdate($userId);
}
}
if (!empty($conn->machineId)) {
NodeRegistry::removeMachine((int) $conn->machineId, $conn);
}
Log::debug("[WS] Machine#{$machineId} disconnected", [
'nodes' => $conn->machineNodeIds,
'total' => NodeRegistry::count(),
'machines' => NodeRegistry::machineCount(),
]);
return;
}
// 旧模式:单节点
if (!empty($conn->nodeId)) {
$nodeId = $conn->nodeId;
NodeRegistry::remove($nodeId, $conn);
Cache::forget("node_ws_alive:{$nodeId}");
$affectedUserIds = $service->clearAllNodeDevices($nodeId);
foreach ($affectedUserIds as $userId) {
$service->notifyUpdate($userId);
}
Log::debug("[WS] Node#{$nodeId} disconnected", [
'total' => NodeRegistry::count(),
'affected_users' => count($affectedUserIds),
]);
}
}
private function subscribeRedis(): void
{
$host = config('database.redis.default.host', '127.0.0.1');
$port = config('database.redis.default.port', 6379);
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);
}
$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;
}
$event = $payload['event'] ?? '';
$data = $payload['data'] ?? [];
// Machine-level events (e.g., sync.nodes)
$machineId = $payload['machine_id'] ?? null;
if ($machineId && $event) {
// Update server-side registry when node membership changes
if ($event === 'sync.nodes') {
$nodeIds = array_map('intval', array_column($data['nodes'] ?? [], 'id'));
NodeRegistry::refreshMachineNodes((int) $machineId, $nodeIds);
}
$sent = NodeRegistry::sendMachine((int) $machineId, $event, $data);
if ($sent) {
Log::debug("[WS] Pushed {$event} to machine#{$machineId}");
}
return;
}
// Per-node events
$nodeId = $payload['node_id'] ?? null;
if (!$nodeId || !$event) {
return;
}
$sent = NodeRegistry::send((int) $nodeId, $event, $data);
if ($sent) {
Log::debug("[WS] Pushed {$event} to node#{$nodeId}");
}
});
Log::info("[WS] Subscribed to Redis channel: {$channel}");
}
}
+7 -4
View File
@@ -2,7 +2,7 @@ services:
web:
image: ghcr.io/cedar2025/xboard:new
volumes:
- ./.docker/.data/redis/:/data/
- redis-data:/data
- ./:/www/
environment:
- docker=true
@@ -14,7 +14,7 @@ services:
horizon:
image: ghcr.io/cedar2025/xboard:new
volumes:
- ./.docker/.data/redis/:/data/
- redis-data:/data
- ./:/www/
restart: always
network_mode: host
@@ -24,7 +24,7 @@ services:
ws-server:
image: ghcr.io/cedar2025/xboard:new
volumes:
- ./.docker/.data/redis/:/data/
- redis-data:/data
- ./:/www/
restart: always
network_mode: host
@@ -36,6 +36,9 @@ services:
command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777
restart: unless-stopped
volumes:
- ./.docker/.data/redis:/data
- redis-data:/data
sysctls:
net.core.somaxconn: 1024
volumes:
redis-data:
Generated
+12076
View File
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@ class CreateV2SettingsTable extends Migration
$table->id();
$table->string('group')->comment('设置分组')->nullable();
$table->string('type')->comment('设置类型')->nullable();
$table->string('name')->comment('设置名称')->uniqid();
$table->string('name')->comment('设置名称')->unique();
$table->string('value')->comment('设置值')->nullable();
$table->timestamps();
});
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('v2_server', function (Blueprint $table) {
if (!Schema::hasColumn('v2_server', 'transfer_enable')) {
$table->bigInteger('transfer_enable')
->default(null)
->nullable()
->after('rate')
->comment('Traffic limit , 0 or null=no limit');
}
if (!Schema::hasColumn('v2_server', 'u')) {
$table->bigInteger('u')
->default(0)
->after('transfer_enable')
->comment('upload traffic');
}
if (!Schema::hasColumn('v2_server', 'd')) {
$table->bigInteger('d')
->default(0)
->after('u')
->comment('donwload traffic');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v2_server', function (Blueprint $table) {
$table->dropColumn(['transfer_enable', 'u', 'd']);
});
}
};
@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('v2_server_machine', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('token', 64)->unique();
$table->text('notes')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedInteger('last_seen_at')->nullable();
$table->json('load_status')->nullable();
$table->timestamps();
});
Schema::create('v2_server_machine_load_history', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('machine_id');
$table->float('cpu')->default(0);
$table->unsignedBigInteger('mem_total')->default(0);
$table->unsignedBigInteger('mem_used')->default(0);
$table->unsignedBigInteger('disk_total')->default(0);
$table->unsignedBigInteger('disk_used')->default(0);
$table->unsignedInteger('recorded_at');
$table->timestamps();
$table->foreign('machine_id')->references('id')->on('v2_server_machine')->cascadeOnDelete();
$table->index(['machine_id', 'recorded_at']);
});
Schema::table('v2_server', function (Blueprint $table) {
$table->unsignedBigInteger('machine_id')->nullable()->after('parent_id');
$table->boolean('enabled')->default(true)->after('show');
$table->foreign('machine_id')->references('id')->on('v2_server_machine')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('v2_server', function (Blueprint $table) {
$table->dropForeign(['machine_id']);
$table->dropColumn(['machine_id', 'enabled']);
});
Schema::dropIfExists('v2_server_machine_load_history');
Schema::dropIfExists('v2_server_machine');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('v2_server_machine_load_history', function (Blueprint $table) {
$table->double('net_in_speed')->nullable()->after('disk_used');
$table->double('net_out_speed')->nullable()->after('net_in_speed');
});
}
public function down(): void
{
Schema::table('v2_server_machine_load_history', function (Blueprint $table) {
$table->dropColumn(['net_in_speed', 'net_out_speed']);
});
}
};
@@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('v2_server', function (Blueprint $table) {
$table->boolean('enabled')->nullable()->default(true)->change();
});
}
public function down(): void
{
Schema::table('v2_server', function (Blueprint $table) {
$table->boolean('enabled')->default(true)->change();
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('v2_mail_templates', function (Blueprint $table) {
$table->id();
$table->string('name', 64)->unique();
$table->string('subject', 255);
$table->longText('content');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('v2_mail_templates');
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Add last_reply_user_id column if not exists
if (!Schema::hasColumn('v2_ticket', 'last_reply_user_id')) {
Schema::table('v2_ticket', function (Blueprint $table) {
$table->integer('last_reply_user_id')->nullable()->after('reply_status');
});
}
// Fix reply_status semantics: swap 0 and 1
// Old: 0=admin replied, 1=user replied (inverted)
// New: 0=待回复(waiting), 1=已回复(replied) — matches frontend expectations
DB::table('v2_ticket')
->whereIn('reply_status', [0, 1])
->update([
'reply_status' => DB::raw("CASE WHEN reply_status = 0 THEN 1 WHEN reply_status = 1 THEN 0 END")
]);
// Fix default: new tickets should be "待回复" (0), not "已回复" (1)
Schema::table('v2_ticket', function (Blueprint $table) {
$table->integer('reply_status')->default(0)->comment('0:待回复 1:已回复')->change();
});
}
public function down(): void
{
// Reverse the swap
DB::table('v2_ticket')
->whereIn('reply_status', [0, 1])
->update([
'reply_status' => DB::raw("CASE WHEN reply_status = 0 THEN 1 WHEN reply_status = 1 THEN 0 END")
]);
Schema::table('v2_ticket', function (Blueprint $table) {
$table->integer('reply_status')->default(1)->comment('0:待回复 1:已回复')->change();
});
// Note: last_reply_user_id column is intentionally kept to avoid dropping
// a column that may have existed before this migration.
}
};
@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('v2_server')
->where('type', 'trojan')
->chunkById(100, function ($servers) {
foreach ($servers as $server) {
$settings = json_decode($server->protocol_settings, true);
if (!$settings) continue;
$rootSni = $settings['server_name'] ?? null;
$rootInsecure = $settings['allow_insecure'] ?? false;
$tlsSettings = $settings['tls_settings'] ?? null;
$needsUpdate = false;
if (!is_array($tlsSettings)) {
if ($rootSni !== null || $rootInsecure) {
$settings['tls_settings'] = [
'server_name' => $rootSni,
'allow_insecure' => (bool) $rootInsecure,
];
$needsUpdate = true;
}
} else {
$tlsSni = $tlsSettings['server_name'] ?? null;
if (($tlsSni === null || $tlsSni === '') && $rootSni !== null && $rootSni !== '') {
$settings['tls_settings']['server_name'] = $rootSni;
$needsUpdate = true;
}
if (($tlsSettings['allow_insecure'] ?? null) === null && $rootInsecure) {
$settings['tls_settings']['allow_insecure'] = true;
$needsUpdate = true;
}
}
if ($needsUpdate) {
DB::table('v2_server')
->where('id', $server->id)
->update(['protocol_settings' => json_encode($settings)]);
}
}
});
}
public function down(): void
{
}
};
+22 -3
View File
@@ -84,7 +84,7 @@ services:
web:
image: ghcr.io/cedar2025/xboard:new
volumes:
- ./.docker/.data/redis/:/data/
- redis-data:/data
- ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs
@@ -104,7 +104,7 @@ services:
horizon:
image: ghcr.io/cedar2025/xboard:new
volumes:
- ./.docker/.data/redis/:/data/
- redis-data:/data
- ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs
@@ -115,6 +115,22 @@ services:
- 1panel-network
depends_on:
- redis
ws-server:
image: ghcr.io/cedar2025/xboard:new
volumes:
- redis-data:/data
- ./.env:/www/.env
- ./.docker/.data/:/www/.docker/.data
- ./storage/logs:/www/storage/logs
- ./plugins:/www/plugins
restart: on-failure
ports:
- 8076:8076
networks:
- 1panel-network
command: php artisan ws-server start
depends_on:
- redis
redis:
image: redis:7-alpine
@@ -123,7 +139,10 @@ services:
networks:
- 1panel-network
volumes:
- ./.docker/.data/redis:/data
- redis-data:/data
volumes:
redis-data:
networks:
1panel-network:
+5 -5
View File
@@ -58,8 +58,8 @@ class Plugin extends AbstractPlugin
"支付渠道:%s\n" .
"本站订单:`%s`",
$order->total_amount / 100,
$payment->payment,
$payment->name,
Helper::escapeMarkdown($payment->payment),
Helper::escapeMarkdown($payment->name),
$order->trade_no
);
$this->telegramService->sendMessageWithAdmin($message, true);
@@ -92,7 +92,7 @@ class Plugin extends AbstractPlugin
$TGmessage .= "📍 位置: `{$region}`\n";
if ($plan) {
$TGmessage .= "📦 套餐: `{$plan->name}`\n";
$TGmessage .= "📦 套餐: `" . Helper::escapeMarkdown($plan->name) . "`\n";
$TGmessage .= "📊 流量: `{$remaining_traffic}G / {$transfer_enable}G` (剩余/总计)\n";
$TGmessage .= "⬆️⬇️ 已用: `{$u}G / {$d}G`\n";
$TGmessage .= "⏰ 到期: `{$expired_at}`\n";
@@ -103,8 +103,8 @@ class Plugin extends AbstractPlugin
$TGmessage .= "💰 余额: `{$money}元`\n";
$TGmessage .= "💸 佣金: `{$affmoney}元`\n";
$TGmessage .= "━━━━━━━━━━━━━━━━━━━━\n";
$TGmessage .= "📝 *主题*: `{$ticket->subject}`\n";
$TGmessage .= "💬 *内容*: `{$message->message}`";
$TGmessage .= "📝 *主题*: `" . Helper::escapeMarkdown($ticket->subject) . "`\n";
$TGmessage .= "💬 *内容*: `" . Helper::escapeMarkdown($message->message) . "`";
$this->telegramService->sendMessageWithAdmin($TGmessage, true);
}
+148
View File
@@ -0,0 +1,148 @@
{
"Article does not exist": "Статья не существует",
"Cancel failed": "Ошибка отмены",
"Close failed": "Ошибка закрытия",
"Coupon cannot be empty": "Купон не может быть пустым",
"Coupon failed": "Ошибка купона",
"Currency conversion has timed out, please try again later": "Время конвертации валюты истекло, попробуйте позже",
"Email already exists": "Эл. почта уже существует",
"Email suffix is not in the Whitelist": "Суффикс эл. почты не в белом списке",
"Email suffix is not in whitelist": "Суффикс эл. почты не в белом списке",
"Email verification code": "Код подтверждения эл. почты",
"Email verification code cannot be empty": "Код подтверждения эл. почты не может быть пустым",
"Email verification code has been sent, please request again later": "Код подтверждения отправлен, запросите повторно позже",
"Failed to create order": "Не удалось создать заказ",
"Failed to open ticket": "Не удалось открыть тикет",
"Gmail alias is not supported": "Псевдоним Gmail не поддерживается",
"Incorrect email or password": "Неверная эл. почта или пароль",
"Incorrect email verification code": "Неверный код подтверждения эл. почты",
"Insufficient balance": "Недостаточно средств",
"Insufficient commission balance": "Недостаточно комиссии",
"Invalid code is incorrect": "Неверный код",
"Invalid coupon": "Недействительный купон",
"Invalid invitation code": "Недействительный код приглашения",
"Invalid parameter": "Недопустимый параметр",
"Message cannot be empty": "Сообщение не может быть пустым",
"No active subscription. Unable to use our provided Apple ID": "Нет активной подписки. Невозможно использовать предоставленный Apple ID",
"Oops, there's a problem... Please refresh the page and try again later": "Ой, возникла проблема... Обновите страницу и попробуйте позже",
"Order does not exist": "Заказ не существует",
"Order does not exist or has been paid": "Заказ не существует или уже оплачен",
"Payment failed. Please check your credit card information": "Ошибка оплаты. Проверьте данные карты",
"Payment gateway request failed": "Ошибка запроса к платёжному шлюзу",
"Payment method is not available": "Способ оплаты недоступен",
"Please wait for the technical enginneer to reply": "Ожидайте ответа технического специалиста",
"Register failed": "Ошибка регистрации",
"Registration has closed": "Регистрация закрыта",
"Reset failed": "Ошибка сброса",
"Save failed": "Ошибка сохранения",
"Subscription has expired or no active subscription, unable to purchase Data Reset Package": "Подписка истекла или нет активной подписки, невозможно приобрести пакет сброса данных",
"Subscription plan does not exist": "Тарифный план не существует",
"The coupon code cannot be used for this subscription": "Код купона не может быть использован для этой подписки",
"The current required minimum withdrawal commission is :limit": "Текущая минимальная комиссия для вывода: :limit",
"The maximum number of creations has been reached": "Достигнуто максимальное количество созданий",
"The old password is wrong": "Неверный старый пароль",
"The ticket is closed and cannot be replied": "Тикет закрыт, ответ невозможен",
"The user does not exist": "Пользователь не существует",
"There are other unresolved tickets": "Есть другие нерешённые тикеты",
"This coupon has expired": "Этот купон истёк",
"This coupon has not yet started": "Этот купон ещё не начался",
"This coupon is no longer available": "Этот купон больше недоступен",
"This email is not registered in the system": "Эта эл. почта не зарегистрирована в системе",
"This payment cycle cannot be purchased, please choose another cycle": "Этот платёжный цикл нельзя приобрести, выберите другой",
"This subscription cannot be renewed, please change to another subscription": "Эту подписку нельзя продлить, выберите другую",
"This subscription has been sold out, please choose another subscription": "Эта подписка распродана, выберите другую",
"This subscription has expired, please change to another subscription": "Эта подписка истекла, выберите другую",
"Ticket does not exist": "Тикет не существует",
"Ticket reply failed": "Ошибка ответа на тикет",
"Token error": "Ошибка токена",
"Transfer failed": "Ошибка перевода",
"Unsupported withdrawal": "Вывод не поддерживается",
"Unsupported withdrawal method": "Способ вывода не поддерживается",
"Withdrawal account": "Счёт для вывода",
"Withdrawal method": "Способ вывода",
"You can only cancel pending orders": "Можно отменить только ожидающие заказы",
"You have an unpaid or pending order, please try again later or cancel it": "У вас есть неоплаченный или ожидающий заказ, попробуйте позже или отмените его",
"You must have a valid subscription to view content in this area": "Необходима действующая подписка для просмотра контента",
"You must use the invitation code to register": "Для регистрации необходимо использовать код приглашения",
"Your account has been suspended": "Ваш аккаунт приостановлен",
"[Commission Withdrawal Request] This ticket is opened by the system": "[Запрос на вывод комиссии] Тикет создан системой",
"Plan ID cannot be empty": "ID тарифа не может быть пустым",
"Plan cycle cannot be empty": "Цикл тарифа не может быть пустым",
"Wrong plan cycle": "Неверный цикл тарифа",
"Ticket subject cannot be empty": "Тема тикета не может быть пустой",
"Ticket level cannot be empty": "Уровень тикета не может быть пустым",
"Incorrect ticket level format": "Неверный формат уровня тикета",
"The withdrawal method cannot be empty": "Способ вывода не может быть пустым",
"The withdrawal account cannot be empty": "Счёт для вывода не может быть пустым",
"Old password cannot be empty": "Старый пароль не может быть пустым",
"New password cannot be empty": "Новый пароль не может быть пустым",
"Password must be greater than 8 digits": "Пароль должен быть длиннее 8 символов",
"The transfer amount cannot be empty": "Сумма перевода не может быть пустой",
"The transfer amount parameter is wrong": "Неверный параметр суммы перевода",
"Incorrect format of expiration reminder": "Неверный формат напоминания об истечении",
"Incorrect traffic alert format": "Неверный формат оповещения о трафике",
"Email can not be empty": "Эл. почта не может быть пустой",
"Email format is incorrect": "Неверный формат эл. почты",
"Password can not be empty": "Пароль не может быть пустым",
"The traffic usage in :app_name has reached 80%": "Использование трафика в :app_name достигло 80%",
"The service in :app_name is about to expire": "Сервис в :app_name скоро истекает",
"The coupon can only be used :limit_use_with_user per person": "Купон можно использовать только :limit_use_with_user раз на человека",
"The coupon code cannot be used for this period": "Код купона не может быть использован для этого периода",
"Request failed, please try again later": "Ошибка запроса, попробуйте позже",
"Register frequently, please try again after :minute minute": "Регистрация слишком частая, попробуйте через :minute минуту",
"Uh-oh, we've had some problems, we're working on it.": "Ой, у нас возникли проблемы, мы работаем над этим",
"This subscription reset package does not apply to your subscription": "Этот пакет сброса не适用于 вашей подписки",
"Login to :name": "Вход в :name",
"Sending frequently, please try again later": "Отправка слишком частая, попробуйте позже",
"Current product is sold out": "Товар распродан",
"There are too many password errors, please try again after :minute minutes.": "Слишком много ошибок пароля, попробуйте через :minute минут",
"Reset failed, Please try again later": "Ошибка сброса, попробуйте позже",
"Subscribe": "Подписаться",
"User Information": "Информация о пользователе",
"Username": "Имя пользователя",
"Status": "Статус",
"Active": "Активен",
"Inactive": "Неактивен",
"Data Used": "Использовано данных",
"Data Limit": "Лимит данных",
"Expiration Date": "Дата истечения",
"Reset In": "Сброс через",
"Days": "Дней",
"Subscription Link": "Ссылка подписки",
"Copy": "Копировать",
"Copied": "Скопировано",
"QR Code": "QR-код",
"Unlimited": "Без ограничений",
"Device Limit": "Лимит устройств",
"Devices": "Устройства",
"No Limit": "Без лимита",
"First Day of Month": "Первый день месяца",
"Monthly": "Ежемесячно",
"Never": "Никогда",
"First Day of Year": "Первый день года",
"Yearly": "Ежегодно",
"update.local_newer": "Текущая версия новее удалённой, сначала закоммитьте изменения",
"update.already_latest": "Уже установлена последняя версия",
"update.process_running": "Процесс обновления уже запущен",
"update.success": "Обновление успешно, с :from до :to, система перезагрузится автоматически",
"update.failed": "Ошибка обновления: :error",
"update.backup_failed": "Ошибка резервного копирования БД: :error",
"update.code_update_failed": "Ошибка обновления кода: :error",
"update.migration_failed": "Ошибка миграции БД: :error",
"update.cache_clear_failed": "Ошибка очистки кэша: :error",
"update.flag_create_failed": "Не удалось создать флаг обновления: :error",
"traffic_reset.reset_type.monthly": "Ежемесячный сброс",
"traffic_reset.reset_type.first_day_month": "Сброс в первый день месяца",
"traffic_reset.reset_type.yearly": "Ежегодный сброс",
"traffic_reset.reset_type.first_day_year": "Сброс в первый день года",
"traffic_reset.reset_type.manual": "Ручной сброс",
"traffic_reset.reset_type.purchase": "Приобретение пакета сброса",
"traffic_reset.source.auto": "Автоматический запуск",
"traffic_reset.source.manual": "Ручной запуск",
"traffic_reset.source.api": "Вызов API",
"traffic_reset.source.cron": "Cron-задача",
"traffic_reset.source.user_access": "Доступ пользователя",
"traffic_reset.reset_success": "Трафик успешно сброшен",
"traffic_reset.reset_failed": "Ошибка сброса трафика, подробности в логах",
"traffic_reset.user_cannot_reset": "Пользователь не может сбросить трафик (аккаунт не активен или нет действующего тарифа)"
}
@@ -1,43 +1,37 @@
<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">登入到{{$name}}</td>
</tr>
<tr>
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
尊敬的用户您好!
<br />
<br />
您正在登入到{{$name}}, 请在 5 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。
<a href="{{$link}}">{{$link}}</a>
</td>
</tr>
<tr style="padding:40px;display:table-cell">
</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>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>邮箱登录</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
<!-- Logo -->
<tr><td style="padding-bottom:24px;text-align:center;">
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
</td></tr>
<!-- Card -->
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">登录确认</td></tr>
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:28px;">点击下方按钮登录到 {{$name}},链接有效期 5 分钟。如非本人操作,请忽略此邮件。</td></tr>
<tr><td align="center" style="padding-bottom:28px;">
<a href="{{$link}}" style="display:inline-block;background:#18181b;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;padding:14px 36px;border-radius:8px;">确认登录</a>
</td></tr>
<tr><td style="font-size:13px;color:#a1a1aa;line-height:1.5;">如果按钮无法点击,请复制以下链接到浏览器中打开:</td></tr>
<tr><td style="font-size:13px;color:#71717a;line-height:1.5;word-break:break-all;padding-top:8px;">{{$link}}</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="padding-top:24px;text-align:center;">
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+35 -42
View File
@@ -1,42 +1,35 @@
<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">网站通知</td>
</tr>
<tr>
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
尊敬的用户您好!
<br />
<br />
{!! nl2br($content) !!}
</td>
</tr>
<tr style="padding:40px;display:table-cell">
</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>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网站通知</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
<!-- Logo -->
<tr><td style="padding-bottom:24px;text-align:center;">
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
</td></tr>
<!-- Card -->
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">网站通知</td></tr>
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:28px;">{!! nl2br($content) !!}</td></tr>
<tr><td align="center">
<a href="{{$url}}" style="display:inline-block;background:#18181b;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;padding:12px 28px;border-radius:8px;">前往查看</a>
</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="padding-top:24px;text-align:center;">
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -1,42 +1,36 @@
<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">到期通知</td>
</tr>
<tr>
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
尊敬的用户您好!
<br />
<br />
你的服务将在24小时内到期。为了不造成使用上的影响请尽快续费。如果你已续费请忽略此邮件。
</td>
</tr>
<tr style="padding:40px;display:table-cell">
</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>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>到期提醒</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
<!-- Logo -->
<tr><td style="padding-bottom:24px;text-align:center;">
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
</td></tr>
<!-- Card -->
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">订阅即将到期</td></tr>
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:12px;">您的订阅服务将在 <strong style="color:#18181b;">24 小时</strong>内到期。</td></tr>
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:28px;">为避免服务中断,请及时续费。如您已完成续费,请忽略此提醒。</td></tr>
<tr><td align="center">
<a href="{{$url}}" style="display:inline-block;background:#18181b;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;padding:12px 28px;border-radius:8px;">立即续费</a>
</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="padding-top:24px;text-align:center;">
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -1,42 +1,36 @@
<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">流量通知</td>
</tr>
<tr>
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
尊敬的用户您好!
<br />
<br />
你的流量已经使用80%。为了不造成使用上的影响请合理安排流量的使用。
</td>
</tr>
<tr style="padding:40px;display:table-cell">
</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>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>流量提醒</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
<!-- Logo -->
<tr><td style="padding-bottom:24px;text-align:center;">
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
</td></tr>
<!-- Card -->
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">流量使用提醒</td></tr>
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:12px;">您本月的套餐流量已使用 <strong style="color:#18181b;">80%</strong></td></tr>
<tr><td style="font-size:15px;color:#52525b;line-height:1.7;padding-bottom:28px;">请合理安排使用,避免提前耗尽。如需更多流量,可前往面板升级套餐。</td></tr>
<tr><td align="center">
<a href="{{$url}}" style="display:inline-block;background:#18181b;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;padding:12px 28px;border-radius:8px;">查看用量</a>
</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="padding-top:24px;text-align:center;">
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+36 -42
View File
@@ -1,42 +1,36 @@
<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">邮箱验证码</td>
</tr>
<tr>
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
尊敬的用户您好!
<br />
<br />
您的验证码是:{{$code}},请在 5 分钟内进行验证。如果该验证码不为您本人申请,请无视。
</td>
</tr>
<tr style="padding:40px;display:table-cell">
</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>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>邮箱验证码</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:40px 20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
<!-- Logo -->
<tr><td style="padding-bottom:24px;text-align:center;">
<span style="font-size:20px;font-weight:700;color:#18181b;">{{$name}}</span>
</td></tr>
<!-- Card -->
<tr><td style="background:#ffffff;border-radius:12px;border:1px solid #e4e4e7;padding:40px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td style="font-size:22px;font-weight:700;color:#18181b;padding-bottom:8px;">邮箱验证码</td></tr>
<tr><td style="font-size:15px;color:#52525b;line-height:1.6;padding-bottom:28px;">请使用以下验证码完成验证,有效期 5 分钟。如非本人操作,请忽略此邮件。</td></tr>
<tr><td align="center" style="padding-bottom:28px;">
<div style="display:inline-block;background:#f4f4f5;border:1px solid #e4e4e7;border-radius:8px;padding:16px 40px;font-size:32px;font-weight:700;letter-spacing:6px;color:#18181b;font-family:'Courier New',Courier,monospace;">{{$code}}</div>
</td></tr>
<tr><td style="font-size:13px;color:#a1a1aa;line-height:1.5;">如果您没有请求此验证码,无需进行任何操作。</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="padding-top:24px;text-align:center;">
<a href="{{$url}}" style="font-size:13px;color:#a1a1aa;text-decoration:none;">{{$url}}</a>
<p style="font-size:12px;color:#d4d4d8;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
-18
View File
@@ -1,19 +1 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
/*
|--------------------------------------------------------------------------
| Console Routes
|--------------------------------------------------------------------------
|
| This file is where you may define all of your Closure based console
| commands. Each Closure is bound to a command instance allowing a
| simple approach to interacting with each command's IO methods.
|
*/
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->describe('Display an inspiring quote');
+4 -1
View File
@@ -21,7 +21,10 @@ use Illuminate\Support\Facades\File;
Route::get('/', function (Request $request) {
if (admin_setting('app_url') && admin_setting('safe_mode_enable', 0)) {
if ($request->server('HTTP_HOST') !== parse_url(admin_setting('app_url'))['host']) {
$requestHost = $request->getHost();
$configHost = parse_url(admin_setting('app_url'), PHP_URL_HOST);
if ($requestHost !== $configHost) {
abort(403);
}
}
+2 -3818
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-27
View File
@@ -1,27 +0,0 @@
window.settings = {
// 站点标题
title: 'V2Board',
// 站点描述
description: 'V2Board is best',
// API
host: '',
// 主题
theme: {
sidebar: 'light',
header: 'dark',
color: 'default'
},
// 背景
background_url: '',
// crisp
crisp_id: '',
i18n: [
'zh-CN',
'en-US',
'ja-JP',
'vi-VN',
'ko-KR',
'zh-TW',
'fa-IR'
]
}
-277
View File
@@ -1,277 +0,0 @@
window.settings.i18n['en-US'] = {
'请求失败': 'Request failed',
'月付': 'Monthly',
'季付': 'Quarterly',
'半年付': 'Semi-Annually',
'年付': 'Annually',
'两年付': 'Biennially',
'三年付': 'Triennially',
'一次性': 'One Time',
'重置流量包': 'Data Reset Package',
'待支付': 'Pending Payment',
'开通中': 'Pending Active',
'已取消': 'Canceled',
'已完成': 'Completed',
'已折抵': 'Converted',
'待确认': 'Pending',
'发放中': 'Confirming',
'已发放': 'Completed',
'无效': 'Invalid',
'个人中心': 'User Center',
'登出': 'Logout',
'搜索': 'Search',
'仪表盘': 'Dashboard',
'订阅': 'Subscription',
'我的订阅': 'My Subscription',
'购买订阅': 'Purchase Subscription',
'财务': 'Billing',
'我的订单': 'My Orders',
'我的邀请': 'My Invitation',
'用户': 'Account',
'我的工单': 'My Tickets',
'流量明细': 'Transfer Data Details',
'使用文档': 'Knowledge Base',
'绑定Telegram获取更多服务': 'Not link to Telegram yet',
'点击这里进行绑定': 'Please click here to link to Telegram',
'公告': 'Announcements',
'总览': 'Overview',
'该订阅长期有效': 'The subscription is valid for an unlimited time',
'已过期': 'Expired',
'已用 {used} / 总计 {total}': '{used} Used / Total {total}',
'查看订阅': 'View Subscription',
'邮箱': 'Email',
'邮箱验证码': 'Email verification code',
'发送': 'Send',
'重置密码': 'Reset Password',
'返回登入': 'Back to Login',
'邀请码': 'Invitation Code',
'复制链接': 'Copy Link',
'完成时间': 'Complete Time',
'佣金': 'Commission',
'已注册用户数': 'Registered users',
'佣金比例': 'Commission rate',
'确认中的佣金': 'Pending commission',
'佣金将会在确认后会到达你的佣金账户。': 'The commission will reach your commission account after review.',
'邀请码管理': 'Invitation Code Management',
'生成邀请码': 'Generate invitation code',
'佣金发放记录': 'Commission Income Record',
'复制成功': 'Copied successfully',
'密码': 'Password',
'登入': 'Login',
'注册': 'Register',
'忘记密码': 'Forgot password',
'# 订单号': 'Order Number #',
'周期': 'Type / Cycle',
'订单金额': 'Order Amount',
'订单状态': 'Order Status',
'创建时间': 'Creation Time',
'操作': 'Action',
'查看详情': 'View Details',
'请选择支付方式': 'Please select a payment method',
'请检查信用卡支付信息': 'Please check credit card payment information',
'订单详情': 'Order Details',
'折扣': 'Discount',
'折抵': 'Converted',
'退款': 'Refund',
'支付方式': 'Payment Method',
'填写信用卡支付信息': 'Please fill in credit card payment information',
'您的信用卡信息只会被用作当次扣款,系统并不会保存,这是我们认为最安全的。': 'We will not collect your credit card information, credit card number and other details only use to verify the current transaction.',
'订单总额': 'Order Total',
'总计': 'Total',
'结账': 'Checkout',
'等待支付中': 'Waiting for payment',
'开通中': 'Pending Active',
'订单系统正在进行处理,请稍等1-3分钟。': 'Order system is being processed, please wait 1 to 3 minutes.',
'已取消': 'Canceled',
'订单由于超时支付已被取消。': 'The order has been canceled due to overtime payment.',
'已完成': 'Completed',
'订单已支付并开通。': 'The order has been paid and the service is activated.',
'选择订阅': 'Select a Subscription',
'立即订阅': 'Subscribe now',
'配置订阅': 'Configure Subscription',
'折扣': 'Discount',
'付款周期': 'Payment Cycle',
'有优惠券?': 'Have coupons?',
'验证': 'Verify',
'订单总额': 'Order Total',
'下单': 'Order',
'总计': 'Total',
'变更订阅会导致当前订阅被新订阅覆盖,请注意。': 'Attention please, change subscription will overwrite your current subscription.',
'该订阅无法续费': 'This subscription cannot be renewed',
'选择其他订阅': 'Choose another subscription',
'我的钱包': 'My Wallet',
'账户余额(仅消费)': 'Account Balance (For billing only)',
'推广佣金(可提现)': 'Invitation Commission (Can be used to withdraw)',
'钱包组成部分': 'Wallet Details',
'划转': 'Transfer',
'推广佣金提现': 'Invitation Commission Withdrawal',
'修改密码': 'Change Password',
'保存': 'Save',
'旧密码': 'Old Password',
'新密码': 'New Password',
'请输入旧密码': 'Please enter the old password',
'请输入新密码': 'Please enter the new password',
'通知': 'Notification',
'到期邮件提醒': 'Subscription expiration email reminder',
'流量邮件提醒': 'Insufficient transfer data email alert',
'绑定Telegram': 'Link to Telegram',
'立即开始': 'Start Now',
'重置订阅信息': 'Reset Subscription',
'重置': 'Reset',
'确定要重置订阅信息?': 'Do you want to reset subscription?',
'如果你的订阅地址或信息泄露可以进行此操作。重置后你的UUID及订阅将会变更,需要重新进行订阅。': 'In case of your account information or subscription leak, this option is for reset. After resetting your UUID and subscription will change, you need to re-subscribe.',
'重置成功': 'Reset successfully',
'两次新密码输入不同': 'Two new passwords entered do not match',
'两次密码输入不同': 'The passwords entered do not match',
'邮箱': 'Email',
'邮箱验证码': 'Email verification code',
'发送': 'Send',
'邀请码': 'Invitation Code',
'邀请码(选填)': 'Invitation code (Optional)',
'注册': 'Register',
'返回登入': 'Back to Login',
'我已阅读并同意 <a target="_blank" href="{url}">服务条款</a>': 'I have read and agree to the <a target="_blank" href="{url}">terms of service</a>',
'请同意服务条款': 'Please agree to the terms of service',
'名称': 'Name',
'标签': 'Tags',
'状态': 'Status',
'节点五分钟内节点在线情况': 'Access Point online status in the last 5 minutes',
'倍率': 'Rate',
'使用的流量将乘以倍率进行扣除': 'The transfer data usage will be multiplied by the transfer data rate deducted.',
'更多操作': 'Action',
'复制成功': 'Copied successfully',
'复制链接': 'Copy Link',
'该订阅长期有效': 'The subscription is valid for an unlimited time',
'已过期': 'Expired',
'已用 {used} / 总计 {total}': '{used} Used / Total {total}',
'重置订阅信息': 'Reset Subscription',
'没有可用节点,如果您未订阅或已过期请': 'No access points are available. If you have not subscribed or the subscription has expired, please',
'订阅': 'Subscription',
'确定重置当前已用流量?': 'Are you sure to reset your current data usage?',
'点击「确定」将会跳转到收银台,支付订单后系统将会清空您当月已使用流量。': 'Click "Confirm" and you will be redirected to the payment page. The system will empty your current month\'s usage after your purchase.',
'确定': 'Confirm',
'确定要重置订阅信息?': 'Do you want to reset subscription?',
'如果你的订阅地址或信息泄露可以进行此操作。重置后你的UUID及订阅将会变更,需要重新进行订阅。': 'In case of your account information or subscription leak, this option is for reset. After resetting your UUID and subscription will change, you need to re-subscribe.',
'重置成功': 'Reset successfully',
'低': 'Low',
'中': 'Medium',
'高': 'High',
'主题': 'Subject',
'工单级别': 'Ticket Priority',
'工单状态': 'Ticket Status',
'最后回复': 'Last Reply',
'已关闭': 'Closed',
'待回复': 'Pending Reply',
'已回复': 'Replied',
'查看': 'View',
'关闭': 'Cancel',
'新的工单': 'My Tickets',
'新的工单': 'My Tickets',
'确认': 'Confirm',
'主题': 'Subject',
'请输入工单主题': 'Please enter a subject',
'工单等级': 'Ticket Priority',
'请选择工单等级': 'Please select the ticket priority',
'消息': 'Message',
'请描述你遇到的问题': 'Please describe the problem you encountered',
'记录时间': 'Record Time',
'实际上行': 'Actual Upload',
'实际下行': 'Actual Download',
'合计': 'Total',
'公式:(实际上行 + 实际下行) x 扣费倍率 = 扣除流量': 'Formula: (Actual Upload + Actual Download) x Deduction Rate = Deduct Transfer Data',
'复制成功': 'Copied successfully',
'复制订阅地址': 'Copy Subscription URL',
'导入到': 'Export to',
'一键订阅': 'Quick Subscription',
'复制订阅': 'Copy Subscription URL',
'推广佣金划转至余额': 'Transfer Invitation Commission to Account Balance',
'确认': 'Confirm',
'划转后的余额仅用于{title}消费使用': 'The transferred balance will be used for {title} payments only',
'当前推广佣金余额': 'Current invitation balance',
'划转金额': 'Transfer amount',
'请输入需要划转到余额的金额': 'Please enter the amount to be transferred to the balance',
'输入内容回复工单...': 'Please enter to reply to the ticket...',
'申请提现': 'Apply For Withdrawal',
'确认': 'Confirm',
'取消': 'Cancel',
'提现方式': 'Withdrawal Method',
'请选择提现方式': 'Please select a withdrawal method',
'提现账号': 'Withdrawal Account',
'请输入提现账号': 'Please enter the withdrawal account',
'我知道了': 'I got it',
'绑定Telegram': 'Link to Telegram',
'第一步': 'First Step',
'第二步': 'Second Step',
'打开Telegram搜索': 'Open Telegram and Search ',
'向机器人发送你的': 'Send the following command to bot',
'使用文档': 'Knowledge Base',
'最后更新: {date}': 'Last Updated: {date}',
'复制成功': 'Copied successfully',
'还有没支付的订单': 'There are still unpaid orders',
'立即支付': 'Pay Now',
'条工单正在处理中': 'tickets are in process',
'立即查看': 'View Now',
'使用文档': 'Knowledge Base',
'我的订单': 'My Orders',
'流量明细': 'Transfer Data Details',
'配置订阅': 'Configure Subscription',
'我的邀请': 'My Invitation',
'节点状态': 'Access Point Status',
'复制成功': 'Copied successfully',
'商品信息': 'Product Information',
'产品名称': 'Product Name',
'类型/周期': 'Type / Cycle',
'产品流量': 'Product Transfer Data',
'订单信息': 'Order Details',
'关闭订单': 'Close order',
'订单号': 'Order Number',
'优惠金额': 'Discount amount',
'旧订阅折抵金额': 'Old subscription converted amount',
'退款金额': 'Refunded amount',
'余额支付': 'Balance payment',
'我的工单': 'My Tickets',
'工单历史': 'Ticket History',
'已用流量将在 {reset_day} 日后重置': 'Used data will reset after {reset_day} days',
'已用流量已在今日重置': 'Data usage has been reset today',
'重置已用流量': 'Reset used data',
'查看节点状态': 'View Access Point status',
'当前已使用流量达{rate}%': 'Currently used data up to {rate}%',
'节点名称': 'Access Point Name',
'于 {date} 到期,距离到期还有 {day} 天。': 'Will expire on {date}, {day} days before expiration, ',
'Telegram 讨论组': 'Telegram Discussion Group',
'立即加入': 'Join Now',
'该订阅无法续费,仅允许新用户购买': 'This subscription cannot be renewed and is only available to new users.',
'重置当月流量': 'Reset current month usage',
'流量明细仅保留近月数据以供查询。': 'Only keep the most recent month\'s usage for checking the transfer data details.',
'扣费倍率': 'Fee deduction rate',
'支付手续费': 'Payment fee',
'续费订阅': 'Renewal Subscription',
'学习如何使用': 'Learn how to use',
'快速将节点导入对应客户端进行使用': 'Quickly export subscription into the client app',
'对您当前的订阅进行续费': 'Renew your current subscription',
'对您当前的订阅进行购买': 'Purchase your current subscription',
'捷径': 'Shortcut',
'不会使用,查看使用教程': 'I am a newbie, view the tutorial',
'使用支持扫码的客户端进行订阅': 'Use a client app that supports scanning QR code to subscribe',
'扫描二维码订阅': 'Scan QR code to subscribe',
'续费': 'Renewal',
'购买': 'Purchase',
'查看教程': 'View Tutorial',
'注意': 'Attention',
'你还有未完成的订单,购买前需要先进行取消,确定取消先前的订单吗?': 'You still have an unpaid order. You need to cancel it before purchasing. Are you sure you want to cancel the previous order?',
'确定取消': 'Confirm Cancel',
'返回我的订单': 'Back to My Order',
'如果你已经付款,取消订单可能会导致支付失败,确定取消订单吗?': 'If you have already paid, canceling the order may cause the payment to fail. Are you sure you want to cancel the order?',
'选择最适合你的计划': 'Choose the right plan for you',
'全部': 'All',
'按周期': 'By Cycle',
'遇到问题': 'I have a problem',
'遇到问题可以通过工单与我们沟通': 'If you have any problems, you can contact us via ticket',
'按流量': 'Pay As You Go',
'搜索文档': 'Search Documents',
'技术支持': 'Technical Support',
'当前剩余佣金': 'Current commission remaining',
'三级分销比例': 'Three-level Distribution Ratio',
'累计获得佣金': 'Cumulative commission earned',
'您邀请的用户再次邀请用户将按照订单金额乘以分销等级的比例进行分成。': 'The users you invite to re-invite users will be divided according to the order amount multiplied by the distribution level.'
};

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