From 225260044ad2e8c5abc4792a6aa27e92f572a453 Mon Sep 17 00:00:00 2001 From: legiz-ru Date: Tue, 23 Sep 2025 13:50:03 +0300 Subject: [PATCH] Add HWID device limit support for sub page based on standart by remnawave: https://remna.st/docs/features/hwid-device-limit --- .gitignore | 1 + app/bot.php | 476 ++++++++++++++++++++++++++++++++++++++++++++++- app/i18n.php | 24 +++ config/hwid.json | 1 + 4 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 config/hwid.json diff --git a/.gitignore b/.gitignore index 3d2d60e..163dfc4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /sftp-config.json /tg.pyr /config/wg0.conf +/config/hwid.json /todo.todo /app/zapretlists/* !/app/zapretlists/.gitkeep diff --git a/app/bot.php b/app/bot.php index 1af7e52..618d3a7 100644 --- a/app/bot.php +++ b/app/bot.php @@ -14,6 +14,7 @@ class Bot public $logs; public $reg; public $pool; + public $hwid; public function __construct($key, $i18n) { @@ -31,6 +32,7 @@ class Bot $this->limit = $this->getPacConf()['limitpage'] ?: 5; $this->adguard = '/config/AdGuardHome.yaml'; $this->update = '/update/json'; + $this->hwid = '/config/hwid.json'; $this->logs = [ 'nginx_default_access', 'nginx_domain_access', @@ -168,6 +170,30 @@ class Bot case preg_match('~^/setIpLimit$~', $this->input['callback'], $m): $this->setIpLimit(); break; + case preg_match('~^/hwidLimit$~', $this->input['callback'], $m): + $this->hwidLimit(); + break; + case preg_match('~^/toggleHwidLimit(?: (\w+))?$~', $this->input['callback'], $m): + $this->toggleHwidLimit($m[1] ?? null); + break; + case preg_match('~^/setHwidDevices(?: (\w+))?$~', $this->input['callback'], $m): + $this->setHwidDevices($m[1] ?? null); + break; + case preg_match('~^/hwidUser (\d+)(?:_(\d+))?$~', $this->input['callback'], $m): + $this->hwidUser($m[1], $m[2] ?? 0); + break; + case preg_match('~^/hwidUserToggle (\d+)$~', $this->input['callback'], $m): + $this->hwidUserToggle($m[1]); + break; + case preg_match('~^/hwidUserDefault (\d+)$~', $this->input['callback'], $m): + $this->hwidUserDefault($m[1]); + break; + case preg_match('~^/setHwidUserLimit (\d+)$~', $this->input['callback'], $m): + $this->setHwidUserLimit($m[1]); + break; + case preg_match('~^/hwidUserDel (\d+)_(\d+) (.+)$~', $this->input['callback'], $m): + $this->hwidUserDel($m[1], $m[2], $m[3]); + break; case preg_match('~^/searchLogs (.+)$~', $this->input['message'], $m): $this->searchLogs($m[1]); break; @@ -4718,6 +4744,228 @@ DNS-over-HTTPS with IP: $this->xray(); } + public function hwidLimit() + { + $pac = $this->getPacConf(); + $enabled = !empty($pac['hwid_limit_enabled']); + $count = max(1, (int) ($pac['hwid_device_count'] ?: 1)); + + $text[] = 'Settings -> ' . $this->i18n('hwid limit'); + $text[] = $this->i18n('hwid notice'); + $text[] = $this->i18n('hwid limit') . ': ' . ($enabled ? $count : $this->i18n('off')); + + $data[] = [ + [ + 'text' => $this->i18n($enabled ? 'on' : 'off'), + 'callback_data' => '/toggleHwidLimit', + ], + ]; + $data[] = [ + [ + 'text' => $this->i18n('set hwid devices count') . ': ' . $count, + 'callback_data' => '/setHwidDevices', + ], + ]; + $data[] = [ + [ + 'text' => $this->i18n('back'), + 'callback_data' => '/xray', + ], + ]; + + $this->update( + $this->input['chat'], + $this->input['message_id'], + implode("\n", $text ?: ['...']), + $data ?: false, + ); + } + + public function toggleHwidLimit($context = null) + { + $pac = $this->getPacConf(); + $pac['hwid_limit_enabled'] = $pac['hwid_limit_enabled'] ? 0 : 1; + if (!empty($pac['hwid_limit_enabled']) && empty($pac['hwid_device_count'])) { + $pac['hwid_device_count'] = 1; + } + $this->setPacConf($pac); + $this->answer($this->input['callback_id'], $this->i18n('hwid notice'), true); + if ($context === 'xray') { + $this->xray(); + } else { + $this->hwidLimit(); + } + } + + public function setHwidDevices($context = null) + { + $r = $this->send( + $this->input['chat'], + "@{$this->input['username']} enter hwid devices count", + $this->input['message_id'], + reply: 'enter hwid devices count', + ); + $_SESSION['reply'][$r['result']['message_id']] = [ + 'start_message' => $this->input['message_id'], + 'callback' => 'saveHwidDevices', + 'args' => [$context], + ]; + } + + public function saveHwidDevices($count, $context = null) + { + $count = (int) $count; + if ($count <= 0) { + $count = 1; + } + $pac = $this->getPacConf(); + $pac['hwid_device_count'] = $count; + $this->setPacConf($pac); + $this->send($this->input['chat'], $this->i18n('hwid notice'), $this->input['message_id']); + if ($context === 'xray') { + $this->xray(); + } else { + $this->hwidLimit(); + } + } + + public function getHwidStorage() + { + if (!file_exists($this->hwid)) { + return []; + } + $data = json_decode(file_get_contents($this->hwid), true); + return is_array($data) ? $data : []; + } + + public function setHwidStorage(array $storage) + { + $dir = dirname($this->hwid); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($this->hwid, json_encode($storage, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + public function getHwidDevicesByUser($uid) + { + $storage = $this->getHwidStorage(); + return $storage[$uid] ?? []; + } + + public function setHwidDevice($uid, $hwid, array $info) + { + $storage = $this->getHwidStorage(); + $storage[$uid][$hwid] = $info; + $this->setHwidStorage($storage); + } + + public function deleteHwidDevice($uid, $hwid) + { + $storage = $this->getHwidStorage(); + if (isset($storage[$uid][$hwid])) { + unset($storage[$uid][$hwid]); + if (empty($storage[$uid])) { + unset($storage[$uid]); + } + $this->setHwidStorage($storage); + } + } + + public function deleteHwidUser($uid) + { + $storage = $this->getHwidStorage(); + if (isset($storage[$uid])) { + unset($storage[$uid]); + $this->setHwidStorage($storage); + } + } + + protected function getHwidTokenScope($index) + { + return ($this->input['chat'] ?? 'global') . ':' . $index; + } + + protected function rememberHwidToken($scope, $hwid) + { + if (!isset($_SESSION['hwidTokens'])) { + $_SESSION['hwidTokens'] = []; + } + if (!isset($_SESSION['hwidTokens'][$scope])) { + $_SESSION['hwidTokens'][$scope] = []; + } + do { + try { + $token = bin2hex(random_bytes(5)); + } catch (\Throwable $e) { + $token = substr(hash('sha256', $hwid . microtime(true)), 0, 10); + } + } while (isset($_SESSION['hwidTokens'][$scope][$token])); + + $_SESSION['hwidTokens'][$scope][$token] = $hwid; + + return $token; + } + + protected function resolveHwidToken($scope, $token) + { + if (isset($_SESSION['hwidTokens'][$scope][$token])) { + $hwid = $_SESSION['hwidTokens'][$scope][$token]; + unset($_SESSION['hwidTokens'][$scope][$token]); + return $hwid; + } + + $decoded = base64_decode($token, true); + + return $decoded !== false ? $decoded : ''; + } + + public function processHwidRequest(array $client) + { + $pac = $this->getPacConf(); + if (empty($pac['hwid_limit_enabled']) || !empty($client['hwid_disabled'])) { + return true; + } + + $limit = (int) ($client['hwid_limit'] ?: ($pac['hwid_device_count'] ?: 0)); + if ($limit <= 0) { + return true; + } + + $devices = $this->getHwidDevicesByUser($client['id']); + $hwid = trim($_SERVER['HTTP_X_HWID'] ?? ''); + $over = false; + + if ($hwid === '') { + if (count($devices) >= $limit) { + $over = true; + } + } else { + $isNew = !isset($devices[$hwid]); + if ($isNew && count($devices) >= $limit) { + $over = true; + } else { + $this->setHwidDevice($client['id'], $hwid, [ + 'time' => time(), + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', + 'device_os' => $_SERVER['HTTP_X_DEVICE_OS'] ?? '', + 'os_version' => $_SERVER['HTTP_X_VER_OS'] ?? '', + 'device_model' => $_SERVER['HTTP_X_DEVICE_MODEL'] ?? '', + ]); + } + } + + if ($over) { + $message = 'HWID device limit exceeded'; + header('announce: base64:' . base64_encode($message)); + header('X-HWID-Status: ' . $message); + header('HTTP/1.1 429 Too Many Requests', true, 429); + return false; + } + + return true; + } + public function switchSilence() { $c = $this->getPacConf(); @@ -5553,6 +5801,7 @@ DNS-over-HTTPS with IP: $st = $this->getXrayStats(); foreach ($r['inbounds'][0]['settings']['clients'] as $k => $v) { if ($i == $k) { + $this->deleteHwidUser($r['inbounds'][0]['settings']['clients'][$k]['id']); unset($r['inbounds'][0]['settings']['clients'][$k]); unset($st['users'][$k]); $this->setXrayStats($st); @@ -6024,13 +6273,25 @@ DNS-over-HTTPS with IP: 'callback_data' => "/changeTransport 1", ], ]; - $ip_count = $p['ip_count'] ?: 1; + $ip_count = $p['ip_count'] ?: 1; + $hwidEnabled = !empty($p['hwid_limit_enabled']); + $defaultHwids = max(1, (int) ($p['hwid_device_count'] ?: 1)); $data[] = [ [ 'text' => $this->i18n('ip limit') . ' ' . ($p['ip_limit'] ? ": {$p['ip_limit']} sec & $ip_count" : $this->i18n('off')), 'callback_data' => "/setIpLimit", ], ]; + $data[] = [ + [ + 'text' => $this->i18n('hwid limit') . ': ' . $this->i18n($hwidEnabled ? 'on' : 'off') . " ({$defaultHwids})", + 'callback_data' => '/toggleHwidLimit xray', + ], + [ + 'text' => $this->i18n('set hwid devices count'), + 'callback_data' => '/setHwidDevices xray', + ], + ]; if ($p['transport'] == 'Reality') { $data[] = [ [ @@ -6416,12 +6677,18 @@ DNS-over-HTTPS with IP: public function userXr($i) { - $c = $this->getXray()['inbounds'][0]['settings']['clients'][$i]; + $xray = $this->getXray(); + $c = $xray['inbounds'][0]['settings']['clients'][$i]; $pac = $this->getPacConf(); $domain = $this->getDomain($pac['transport'] != 'Reality'); $scheme = empty($this->nginxGetTypeCert()) ? 'http' : 'https'; $hash = $this->getHashBot(); + $devices = $this->getHwidDevicesByUser($c['id']); + $hwidEnabled = !empty($pac['hwid_limit_enabled']) && empty($c['hwid_disabled']); + $defaultHwid = max(1, (int) ($pac['hwid_device_count'] ?: 1)); + $hwidLimit = $c['hwid_limit'] ? (int) $c['hwid_limit'] : $defaultHwid; + $text[] = "Menu -> " . $this->i18n('xray') . " -> {$c['email']}\n"; if (file_exists(__DIR__ . '/subscription.php')) { $text[] = "subscription"; @@ -6468,15 +6735,15 @@ DNS-over-HTTPS with IP: $data[] = [ [ 'text' => $this->i18n('v2ray'), - 'web_app' => ['url' => "https://{$domain}/pac$hash?t=s&s={$c['id']}"], + 'web_app' => ['url' => "https://{$domain}/pac$hash?t=s&s={$c['id']}"] ], [ 'text' => $this->i18n('singbox'), - 'web_app' => ['url' => "https://{$domain}/pac$hash?t=si&s={$c['id']}"], + 'web_app' => ['url' => "https://{$domain}/pac$hash?t=si&s={$c['id']}"] ], [ 'text' => $this->i18n('mihomo'), - 'web_app' => ['url' => "https://{$domain}/pac$hash?t=cl&s={$c['id']}"], + 'web_app' => ['url' => "https://{$domain}/pac$hash?t=cl&s={$c['id']}"] ], ]; $data[] = [ @@ -6520,6 +6787,12 @@ DNS-over-HTTPS with IP: 'callback_data' => "/qrXray {$i}_2", ], ]; + $data[] = [ + [ + 'text' => $this->i18n('hwid limit') . ': ' . ($hwidEnabled ? $hwidLimit : $this->i18n('off')) . ' (' . count($devices) . ')', + 'callback_data' => "/hwidUser $i", + ], + ]; $data[] = [ [ 'text' => $this->i18n('rename'), @@ -6544,6 +6817,188 @@ DNS-over-HTTPS with IP: ); } + public function hwidUser($i, $page = 0) + { + $xray = $this->getXray(); + $client = $xray['inbounds'][0]['settings']['clients'][$i]; + $pac = $this->getPacConf(); + + $devices = $this->getHwidDevicesByUser($client['id']); + $scope = $this->getHwidTokenScope($i); + if (!isset($_SESSION['hwidTokens'])) { + $_SESSION['hwidTokens'] = []; + } + $_SESSION['hwidTokens'][$scope] = []; + uasort($devices, fn($a, $b) => ($b['time'] ?? 0) <=> ($a['time'] ?? 0)); + $hwids = array_keys($devices); + $perPage = max(1, $this->limit ?: 5); + $total = count($hwids); + $pages = max(1, (int) ceil($total / $perPage)); + $page = min(max((int) $page, 0), $pages - 1); + $hwidsPage = array_slice($hwids, $page * $perPage, $perPage); + $defaultHwid = max(1, (int) ($pac['hwid_device_count'] ?: 1)); + + $text[] = "Menu -> " . $this->i18n('xray') . " -> {$client['email']} -> " . $this->i18n('hwid devices'); + $text[] = $this->i18n('hwid notice'); + if (empty($pac['hwid_limit_enabled'])) { + $status = $this->i18n('off'); + } elseif (!empty($client['hwid_disabled'])) { + $status = $this->i18n('off'); + } elseif (!empty($client['hwid_limit'])) { + $status = (int) $client['hwid_limit']; + } else { + $status = "default($defaultHwid)"; + } + $text[] = $this->i18n('hwid limit') . ': ' . $status; + $text[] = $this->i18n('hwid devices') . ': ' . $total; + + $data[] = [ + [ + 'text' => $this->i18n(!empty($client['hwid_disabled']) ? 'off' : 'on'), + 'callback_data' => "/hwidUserToggle $i", + ], + ]; + $data[] = [ + [ + 'text' => $this->i18n('set hwid devices count'), + 'callback_data' => "/setHwidUserLimit $i", + ], + ]; + if (!empty($client['hwid_limit'])) { + $data[] = [ + [ + 'text' => $this->i18n('use default hwid limit'), + 'callback_data' => "/hwidUserDefault $i", + ], + ]; + } + + if ($total == 0) { + $text[] = $this->i18n('no devices'); + } + + foreach ($hwidsPage as $index => $hwid) { + $info = $devices[$hwid]; + $number = $page * $perPage + $index + 1; + $details = array_filter([ + $info['device_os'] ?? '', + $info['os_version'] ?? '', + $info['device_model'] ?? '', + ], fn($v) => $v !== ''); + $line = $number . '. ' . htmlspecialchars($hwid, ENT_HTML5, 'UTF-8') . ''; + if (!empty($details)) { + $line .= ' - ' . htmlspecialchars(implode(' ', $details), ENT_HTML5, 'UTF-8'); + } + if (!empty($info['time'])) { + $line .= ' (' . date('d.m.Y H:i', $info['time']) . ')'; + } + $text[] = $line; + if (!empty($info['user_agent'])) { + $text[] = 'UA: ' . htmlspecialchars($info['user_agent'], ENT_HTML5, 'UTF-8'); + } + $token = $this->rememberHwidToken($scope, $hwid); + $data[] = [ + [ + 'text' => 'πŸ—‘ ' . $number, + 'callback_data' => "/hwidUserDel {$i}_{$page} $token", + ], + ]; + } + + if ($pages > 1) { + $data[] = [ + [ + 'text' => '<<', + 'callback_data' => "/hwidUser {$i}_" . ($page - 1 >= 0 ? $page - 1 : $pages - 1), + ], + [ + 'text' => ($page + 1) . '/' . $pages, + 'callback_data' => "/hwidUser {$i}_$page", + ], + [ + 'text' => '>>', + 'callback_data' => "/hwidUser {$i}_" . (($page + 1) % $pages), + ], + ]; + } + + $data[] = [ + [ + 'text' => $this->i18n('back'), + 'callback_data' => "/userXr $i", + ], + ]; + + $this->update( + $this->input['chat'], + $this->input['message_id'], + implode("\n", $text ?: ['...']), + $data ?: false, + ); + } + + public function hwidUserToggle($i) + { + $xray = $this->getXray(); + if (!empty($xray['inbounds'][0]['settings']['clients'][$i]['hwid_disabled'])) { + unset($xray['inbounds'][0]['settings']['clients'][$i]['hwid_disabled']); + } else { + $xray['inbounds'][0]['settings']['clients'][$i]['hwid_disabled'] = 1; + } + file_put_contents('/config/xray.json', json_encode($xray, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $this->answer($this->input['callback_id'], $this->i18n('hwid notice'), true); + $this->hwidUser($i); + } + + public function hwidUserDefault($i) + { + $xray = $this->getXray(); + unset($xray['inbounds'][0]['settings']['clients'][$i]['hwid_limit']); + file_put_contents('/config/xray.json', json_encode($xray, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $this->hwidUser($i); + } + + public function setHwidUserLimit($i) + { + $r = $this->send( + $this->input['chat'], + "@{$this->input['username']} enter hwid devices count", + $this->input['message_id'], + reply: 'enter hwid devices count', + ); + $_SESSION['reply'][$r['result']['message_id']] = [ + 'start_message' => $this->input['message_id'], + 'callback' => 'saveHwidUserLimit', + 'args' => [$i], + ]; + } + + public function saveHwidUserLimit($count, $i) + { + $xray = $this->getXray(); + $count = (int) $count; + if ($count > 0) { + $xray['inbounds'][0]['settings']['clients'][$i]['hwid_limit'] = $count; + } else { + unset($xray['inbounds'][0]['settings']['clients'][$i]['hwid_limit']); + } + file_put_contents('/config/xray.json', json_encode($xray, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $this->send($this->input['chat'], $this->i18n('hwid notice'), $this->input['message_id']); + $this->hwidUser($i); + } + + public function hwidUserDel($i, $page, $hwid) + { + $xray = $this->getXray(); + $uid = $xray['inbounds'][0]['settings']['clients'][$i]['id']; + $scope = $this->getHwidTokenScope($i); + $decoded = $this->resolveHwidToken($scope, $hwid); + if ($decoded !== '') { + $this->deleteHwidDevice($uid, $decoded); + } + $this->hwidUser($i, $page); + } + public function getDomain($cdn = false) { $c = $this->getPacConf(); @@ -6562,6 +7017,7 @@ DNS-over-HTTPS with IP: $scheme = empty($this->nginxGetTypeCert()) ? 'http' : 'https'; $hash = $this->getHashBot(); $flag = true; + $client = null; foreach ($xr['inbounds'][0]['settings']['clients'] as $k => $v) { if ($v['id'] == $_GET['id']) { if (empty($v['off'])) { @@ -6570,9 +7026,13 @@ DNS-over-HTTPS with IP: $uid = $v['id']; $email = $v['email']; $expire = $v['time']; + $client = $v; break; } } + if (!$flag && !$this->processHwidRequest($client)) { + exit; + } $suburl = "subscription"; $download = $this->getBytes($st['users'][$k]['global']['download'] + $st['users'][$k]['session']['download']); $upload = $this->getBytes($st['users'][$k]['global']['upload'] + $st['users'][$k]['session']['upload']); @@ -6625,6 +7085,7 @@ DNS-over-HTTPS with IP: $hash = $this->getHashBot(); $flag = true; + $client = null; foreach ($xr['inbounds'][0]['settings']['clients'] as $k => $v) { if ($v['id'] == $_GET['s']) { if (empty($v['off'])) { @@ -6633,6 +7094,7 @@ DNS-over-HTTPS with IP: $template = base64_decode($v["{$type}template"]); $uid = $v['id']; $email = $v['email']; + $client = $v; break; } } @@ -6641,6 +7103,10 @@ DNS-over-HTTPS with IP: exit; } + if (!$return && !$this->processHwidRequest($client)) { + exit; + } + if (!empty($_GET['r'])) { $si = "$scheme://{$domain}/pac$hash/" . base64_encode(serialize([ 'h' => $hash, diff --git a/app/i18n.php b/app/i18n.php index d541016..756f98c 100644 --- a/app/i18n.php +++ b/app/i18n.php @@ -193,6 +193,30 @@ $i = [ 'en' => 'delete internal dns', 'ru' => 'ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ Π²Π½ΡƒΡ‚Ρ€Π΅Π½Π½ΠΈΠΉ dns', ], + 'hwid limit' => [ + 'en' => 'HWID limit', + 'ru' => 'HWID Π»ΠΈΠΌΠΈΡ‚', + ], + 'hwid devices' => [ + 'en' => 'HWID devices', + 'ru' => 'Устройства HWID', + ], + 'set hwid devices count' => [ + 'en' => 'set HWID devices count', + 'ru' => 'ΡƒΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ количСство HWID устройств', + ], + 'use default hwid limit' => [ + 'en' => 'use default limit', + 'ru' => 'ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ Π»ΠΈΠΌΠΈΡ‚ ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ', + ], + 'no devices' => [ + 'en' => 'no devices', + 'ru' => 'Π½Π΅Ρ‚ устройств', + ], + 'hwid notice' => [ + 'en' => 'HWID limit works only when subscription is refreshed or added', + 'ru' => 'HWID Π»ΠΈΠΌΠΈΡ‚ срабатываСт Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² ΠΌΠΎΠΌΠ΅Π½Ρ‚ обновлСния ΠΈ добавлСния подписки', + ], 'on' => [ 'en' => '🟒', 'ru' => '🟒', diff --git a/config/hwid.json b/config/hwid.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/config/hwid.json @@ -0,0 +1 @@ +{}