merge: sync upstream/master from cedar2025/Xboard
合并上游 cedar2025/Xboard 的 master,并按交互决策保留本地改动。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\InviteCode;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
@@ -113,7 +114,7 @@ class RegisterService
|
||||
|
||||
if (!$inviteCodeModel) {
|
||||
if ((int) admin_setting('invite_force', 0)) {
|
||||
throw new \Exception(__('Invalid invitation code'));
|
||||
throw new ApiException(__('Invalid invitation code'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ class GiftCardService
|
||||
$userService->assignPlan(
|
||||
$this->user,
|
||||
$plan,
|
||||
$rewards['plan_validity_days'] ?? null
|
||||
$rewards['plan_validity_days'] ?? 0
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -13,6 +13,33 @@ use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class MailService
|
||||
{
|
||||
// Render {{key}} / {{key|default}} placeholders.
|
||||
private static function renderPlaceholders(string $template, array $vars): string
|
||||
{
|
||||
if ($template === '' || empty($vars)) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
return (string) preg_replace_callback('/\{\{\s*([a-zA-Z0-9_.-]+)(?:\|([^}]*))?\s*\}\}/', function ($m) use ($vars) {
|
||||
$key = $m[1] ?? '';
|
||||
$default = array_key_exists(2, $m) ? trim((string) $m[2]) : null;
|
||||
|
||||
if (!array_key_exists($key, $vars) || $vars[$key] === null || $vars[$key] === '') {
|
||||
return $default !== null ? $default : $m[0];
|
||||
}
|
||||
|
||||
$value = $vars[$key];
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
|
||||
}, $template);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需要发送提醒的用户总数
|
||||
*/
|
||||
@@ -233,19 +260,44 @@ class MailService
|
||||
}
|
||||
|
||||
if (array_key_exists('content', $params['template_value'])) {
|
||||
$params['template_value']['content'] = self::sanitizeMailText((string) $params['template_value']['content']);
|
||||
$params['template_value']['content'] = (string) $params['template_value']['content'];
|
||||
}
|
||||
|
||||
$email = (string) $params['email'];
|
||||
$subject = self::sanitizeMailText((string) $params['subject']);
|
||||
$originTemplateName = (string) $params['template_name'];
|
||||
$subject = (string) $params['subject'];
|
||||
|
||||
$vars = is_array($params['template_value']) ? ($params['template_value']['vars'] ?? []) : [];
|
||||
$contentMode = is_array($params['template_value']) ? ($params['template_value']['content_mode'] ?? null) : null;
|
||||
|
||||
if (is_array($vars) && !empty($vars)) {
|
||||
$subject = self::renderPlaceholders($subject, $vars);
|
||||
|
||||
if (isset($params['template_value']['content']) && is_string($params['template_value']['content'])) {
|
||||
$params['template_value']['content'] = self::renderPlaceholders($params['template_value']['content'], $vars);
|
||||
}
|
||||
}
|
||||
|
||||
$subject = self::sanitizeMailText($subject);
|
||||
if ($subject === '') {
|
||||
$subject = 'Notification';
|
||||
}
|
||||
|
||||
$originTemplateName = (string) $params['template_name'];
|
||||
if (array_key_exists('content', $params['template_value'])) {
|
||||
$params['template_value']['content'] = self::sanitizeMailText((string) $params['template_value']['content']);
|
||||
}
|
||||
|
||||
if (
|
||||
$contentMode === 'text'
|
||||
&& $originTemplateName !== 'notify'
|
||||
&& isset($params['template_value']['content'])
|
||||
&& is_string($params['template_value']['content'])
|
||||
) {
|
||||
$params['template_value']['content'] = e($params['template_value']['content']);
|
||||
}
|
||||
|
||||
$params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $originTemplateName;
|
||||
$logTemplateName = $params['template_name'];
|
||||
|
||||
try {
|
||||
if ($originTemplateName === 'notify') {
|
||||
$html = self::buildModernNotifyHtml($params['template_value'], $subject, $appName);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Workerman\Connection\TcpConnection;
|
||||
|
||||
/**
|
||||
* In-memory registry for active WebSocket node connections.
|
||||
* Runs inside the Workerman process.
|
||||
*/
|
||||
class NodeRegistry
|
||||
{
|
||||
/** @var array<int, TcpConnection> nodeId → connection */
|
||||
private static array $connections = [];
|
||||
|
||||
public static function add(int $nodeId, TcpConnection $conn): void
|
||||
{
|
||||
// Close existing connection for this node (if reconnecting)
|
||||
if (isset(self::$connections[$nodeId])) {
|
||||
self::$connections[$nodeId]->close();
|
||||
}
|
||||
self::$connections[$nodeId] = $conn;
|
||||
}
|
||||
|
||||
public static function remove(int $nodeId): void
|
||||
{
|
||||
unset(self::$connections[$nodeId]);
|
||||
}
|
||||
|
||||
public static function get(int $nodeId): ?TcpConnection
|
||||
{
|
||||
return self::$connections[$nodeId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON message to a specific node.
|
||||
*/
|
||||
public static function send(int $nodeId, string $event, array $data): bool
|
||||
{
|
||||
$conn = self::get($nodeId);
|
||||
if (!$conn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'event' => $event,
|
||||
'data' => $data,
|
||||
'timestamp' => time(),
|
||||
]);
|
||||
|
||||
$conn->send($payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the connection for a node by ID, checking if it's still alive.
|
||||
*/
|
||||
public static function isOnline(int $nodeId): bool
|
||||
{
|
||||
$conn = self::get($nodeId);
|
||||
return $conn !== null && $conn->getStatus() === TcpConnection::STATUS_ESTABLISHED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected node IDs.
|
||||
* @return int[]
|
||||
*/
|
||||
public static function getConnectedNodeIds(): array
|
||||
{
|
||||
return array_keys(self::$connections);
|
||||
}
|
||||
|
||||
public static function count(): int
|
||||
{
|
||||
return count(self::$connections);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class NodeSyncService
|
||||
{
|
||||
/**
|
||||
* Check if node has active WS connection
|
||||
*/
|
||||
private static function isNodeOnline(int $nodeId): bool
|
||||
{
|
||||
return (bool) Cache::get("node_ws_alive:{$nodeId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Push node config update
|
||||
*/
|
||||
public static function notifyConfigUpdated(int $nodeId): void
|
||||
{
|
||||
if (!self::isNodeOnline($nodeId))
|
||||
return;
|
||||
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node)
|
||||
return;
|
||||
|
||||
|
||||
self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push all users to all nodes in the group
|
||||
*/
|
||||
public static function notifyUsersUpdatedByGroup(int $groupId): void
|
||||
{
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $groupId)
|
||||
->get();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
if (!self::isNodeOnline($server->id))
|
||||
continue;
|
||||
|
||||
$users = ServerService::getAvailableUsers($server)->toArray();
|
||||
self::push($server->id, 'sync.users', ['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push user changes (add/remove) to affected nodes
|
||||
*/
|
||||
public static function notifyUserChanged(User $user): void
|
||||
{
|
||||
if (!$user->group_id)
|
||||
return;
|
||||
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $user->group_id)->get();
|
||||
foreach ($servers as $server) {
|
||||
if (!self::isNodeOnline($server->id))
|
||||
continue;
|
||||
|
||||
if ($user->isAvailable()) {
|
||||
self::push($server->id, 'sync.user.delta', [
|
||||
'action' => 'add',
|
||||
'users' => [
|
||||
[
|
||||
'id' => $user->id,
|
||||
'uuid' => $user->uuid,
|
||||
'speed_limit' => $user->speed_limit,
|
||||
'device_limit' => $user->device_limit,
|
||||
]
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
self::push($server->id, 'sync.user.delta', [
|
||||
'action' => 'remove',
|
||||
'users' => [['id' => $user->id]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push user removal from a specific group's nodes
|
||||
*/
|
||||
public static function notifyUserRemovedFromGroup(int $userId, int $groupId): void
|
||||
{
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $groupId)
|
||||
->get();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
if (!self::isNodeOnline($server->id))
|
||||
continue;
|
||||
|
||||
self::push($server->id, 'sync.user.delta', [
|
||||
'action' => 'remove',
|
||||
'users' => [['id' => $userId]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sync: push config + users to a node
|
||||
*/
|
||||
public static function notifyFullSync(int $nodeId): void
|
||||
{
|
||||
if (!self::isNodeOnline($nodeId))
|
||||
return;
|
||||
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node)
|
||||
return;
|
||||
|
||||
self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]);
|
||||
|
||||
$users = ServerService::getAvailableUsers($node)->toArray();
|
||||
self::push($nodeId, 'sync.users', ['users' => $users]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a push command to Redis — picked up by the Workerman WS server
|
||||
*/
|
||||
private static function push(int $nodeId, string $event, array $data): void
|
||||
{
|
||||
try {
|
||||
Redis::publish('node:push', json_encode([
|
||||
'node_id' => $nodeId,
|
||||
'event' => $event,
|
||||
'data' => $data,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[NodePush] Redis publish failed: {$e->getMessage()}", [
|
||||
'node_id' => $nodeId,
|
||||
'event' => $event,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,13 +95,14 @@ class OrderService
|
||||
public function open(): void
|
||||
{
|
||||
$order = $this->order;
|
||||
$this->user = User::find($order->user_id);
|
||||
$plan = Plan::find($order->plan_id);
|
||||
|
||||
HookManager::call('order.open.before', $order);
|
||||
|
||||
|
||||
DB::transaction(function () use ($order, $plan) {
|
||||
$this->user = User::lockForUpdate()->find($order->user_id);
|
||||
|
||||
if ($order->refund_amount) {
|
||||
$this->user->balance += $order->refund_amount;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ class ServerService
|
||||
'is_online',
|
||||
'available_status',
|
||||
'cache_key',
|
||||
'load_status'
|
||||
'load_status',
|
||||
'metrics',
|
||||
'online_conn'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -54,6 +56,7 @@ class ServerService
|
||||
$server->port = (int) $server->port;
|
||||
}
|
||||
$server->password = $server->generateServerPassword($user);
|
||||
$server->rate = $server->getCurrentRate();
|
||||
return $server;
|
||||
})->toArray();
|
||||
|
||||
@@ -92,6 +95,174 @@ class ServerService
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node metrics and load status
|
||||
*/
|
||||
public static function updateMetrics(Server $node, array $metrics): void
|
||||
{
|
||||
$nodeType = strtoupper($node->type);
|
||||
$nodeId = $node->id;
|
||||
$cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3);
|
||||
|
||||
$metricsData = [
|
||||
'uptime' => (int) ($metrics['uptime'] ?? 0),
|
||||
'goroutines' => (int) ($metrics['goroutines'] ?? 0),
|
||||
'active_connections' => (int) ($metrics['active_connections'] ?? 0),
|
||||
'total_connections' => (int) ($metrics['total_connections'] ?? 0),
|
||||
'total_users' => (int) ($metrics['total_users'] ?? 0),
|
||||
'active_users' => (int) ($metrics['active_users'] ?? 0),
|
||||
'inbound_speed' => (int) ($metrics['inbound_speed'] ?? 0),
|
||||
'outbound_speed' => (int) ($metrics['outbound_speed'] ?? 0),
|
||||
'cpu_per_core' => $metrics['cpu_per_core'] ?? [],
|
||||
'load' => $metrics['load'] ?? [],
|
||||
'speed_limiter' => $metrics['speed_limiter'] ?? [],
|
||||
'gc' => $metrics['gc'] ?? [],
|
||||
'api' => $metrics['api'] ?? [],
|
||||
'ws' => $metrics['ws'] ?? [],
|
||||
'limits' => $metrics['limits'] ?? [],
|
||||
'updated_at' => now()->timestamp,
|
||||
'kernel_status' => (bool) ($metrics['kernel_status'] ?? false),
|
||||
];
|
||||
|
||||
\Illuminate\Support\Facades\Cache::put(
|
||||
\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId),
|
||||
$metricsData,
|
||||
$cacheTime
|
||||
);
|
||||
}
|
||||
|
||||
public static function buildNodeConfig(Server $node): array
|
||||
{
|
||||
$nodeType = $node->type;
|
||||
$protocolSettings = $node->protocol_settings;
|
||||
$serverPort = $node->server_port;
|
||||
$host = $node->host;
|
||||
|
||||
$baseConfig = [
|
||||
'protocol' => $nodeType,
|
||||
'listen_ip' => '0.0.0.0',
|
||||
'server_port' => (int) $serverPort,
|
||||
'network' => data_get($protocolSettings, 'network'),
|
||||
'networkSettings' => data_get($protocolSettings, 'network_settings') ?: null,
|
||||
];
|
||||
|
||||
$response = match ($nodeType) {
|
||||
'shadowsocks' => [
|
||||
...$baseConfig,
|
||||
'cipher' => $protocolSettings['cipher'],
|
||||
'plugin' => $protocolSettings['plugin'],
|
||||
'plugin_opts' => $protocolSettings['plugin_opts'],
|
||||
'server_key' => match ($protocolSettings['cipher']) {
|
||||
'2022-blake3-aes-128-gcm' => Helper::getServerKey($node->created_at, 16),
|
||||
'2022-blake3-aes-256-gcm' => Helper::getServerKey($node->created_at, 32),
|
||||
default => null,
|
||||
},
|
||||
],
|
||||
'vmess' => [
|
||||
...$baseConfig,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
],
|
||||
'trojan' => [
|
||||
...$baseConfig,
|
||||
'host' => $host,
|
||||
'server_name' => $protocolSettings['server_name'],
|
||||
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
],
|
||||
'vless' => [
|
||||
...$baseConfig,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'flow' => $protocolSettings['flow'],
|
||||
'tls_settings' => match ((int) $protocolSettings['tls']) {
|
||||
2 => $protocolSettings['reality_settings'],
|
||||
default => $protocolSettings['tls_settings'],
|
||||
},
|
||||
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
],
|
||||
'hysteria' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'version' => (int) $protocolSettings['version'],
|
||||
'host' => $host,
|
||||
'server_name' => $protocolSettings['tls']['server_name'],
|
||||
'up_mbps' => (int) $protocolSettings['bandwidth']['up'],
|
||||
'down_mbps' => (int) $protocolSettings['bandwidth']['down'],
|
||||
...match ((int) $protocolSettings['version']) {
|
||||
1 => ['obfs' => $protocolSettings['obfs']['password'] ?? null],
|
||||
2 => [
|
||||
'obfs' => $protocolSettings['obfs']['open'] ? $protocolSettings['obfs']['type'] : null,
|
||||
'obfs-password' => $protocolSettings['obfs']['password'] ?? null,
|
||||
],
|
||||
default => [],
|
||||
},
|
||||
],
|
||||
'tuic' => [
|
||||
...$baseConfig,
|
||||
'version' => (int) $protocolSettings['version'],
|
||||
'server_port' => (int) $serverPort,
|
||||
'server_name' => $protocolSettings['tls']['server_name'],
|
||||
'congestion_control' => $protocolSettings['congestion_control'],
|
||||
'tls_settings' => data_get($protocolSettings, 'tls_settings'),
|
||||
'auth_timeout' => '3s',
|
||||
'zero_rtt_handshake' => false,
|
||||
'heartbeat' => '3s',
|
||||
],
|
||||
'anytls' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'server_name' => $protocolSettings['tls']['server_name'],
|
||||
'padding_scheme' => $protocolSettings['padding_scheme'],
|
||||
],
|
||||
'socks' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
],
|
||||
'naive' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'tls_settings' => $protocolSettings['tls_settings'],
|
||||
],
|
||||
'http' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'tls_settings' => $protocolSettings['tls_settings'],
|
||||
],
|
||||
'mieru' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'transport' => data_get($protocolSettings, 'transport', 'TCP'),
|
||||
'traffic_pattern' => $protocolSettings['traffic_pattern'],
|
||||
// 'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$response = array_filter(
|
||||
$response,
|
||||
static fn ($value) => $value !== null
|
||||
);
|
||||
|
||||
if (!empty($node['route_ids'])) {
|
||||
$response['routes'] = self::getRoutes($node['route_ids']);
|
||||
}
|
||||
|
||||
if (!empty($node['custom_outbounds'])) {
|
||||
$response['custom_outbounds'] = $node['custom_outbounds'];
|
||||
}
|
||||
|
||||
if (!empty($node['custom_routes'])) {
|
||||
$response['custom_routes'] = $node['custom_routes'];
|
||||
}
|
||||
|
||||
if (!empty($node['cert_config'])) {
|
||||
$response['cert_config'] = $node['cert_config'];
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据协议类型和标识获取服务器
|
||||
* @param int $serverId
|
||||
|
||||
@@ -173,7 +173,7 @@ class UserService
|
||||
// 默认设置
|
||||
$user->remind_expire = admin_setting('default_remind_expire', 1);
|
||||
$user->remind_traffic = admin_setting('default_remind_traffic', 1);
|
||||
$user->expired_at = 0;
|
||||
$user->expired_at = null;
|
||||
|
||||
// 可选字段
|
||||
$this->setOptionalFields($user, $data);
|
||||
@@ -242,6 +242,7 @@ class UserService
|
||||
$user->group_id = $plan->group_id;
|
||||
$user->transfer_enable = $plan->transfer_enable * 1073741824;
|
||||
$user->speed_limit = $plan->speed_limit;
|
||||
$user->device_limit = $plan->device_limit;
|
||||
|
||||
if ($validityDays > 0) {
|
||||
$user = $this->extendSubscription($user, $validityDays);
|
||||
|
||||
Reference in New Issue
Block a user