Использование HWID в xray-core

Всем привет!
Моя первая тема тут, прошу сильно не пинать, если накосячил с оформлением )

Использую голый xray-core, но захотелось оградить себя от расползания подписок и прикрутить проверку hwid. Перевел всех пользователей на incy. Думаю, это все можно переделать для любого другого клиента с поддержкой hwid, кому какой нравится.
Использую Debian 13, веб сервер Angie - форк Nginx. В Nginx все будет аналогично, кроме работы с Lets Encrypt.

Как это все выглядит в работе:

  1. Пользователю передается ссылка, опционально закрытая на incy[.]cc/encrypt/
  2. Он вводит эту ссылку в клиенте, клиент ругается 503 Forbidden.
  3. Админ видит в логе /var/log/angie/sub_access.log неудачное обращение к подписке с указанием hwid клиента.
  4. Если доступ согласован, админ прописывает hwid в базу.
  5. Пользователь еще раз добавляет ту же ссылку, подписка успешно добавляется, скачиваются и применяются настройки роутинга.
  6. Все счастливы.

Содержимое подписок скрыто от пользователей, поделиться ими они не могут (add_header hide-url “1” always;).

Параметр hide-url блокирует «утечку» URL подписки за пределы устройства: при включении приложение не показывает и не разрешает экспортировать URL ни через Share, ни через Copy URL, ни через QR-код, ни в резервные копии. Имя подписки, серверы и весь остальной UX работают как обычно — скрывается только сам URL.

Сломать все это при желании можно, но у меня не тот контингент )

Соавтор и помощник: Gemini )

А теперь более детально.

  1. Делаем, чтобы hwid, посылаемый клиентами, было видно в логах веб сервера.
В /etc/angie/angie.conf в блок http добавляем:
http {
...
    # Создаем новый формат логов с именем 'vpn_log'
    log_format vpn_log '$remote_addr - $remote_user [$time_local] '
                       '"$request" $status $body_bytes_sent '
                       '"$http_referer" "$http_user_agent" '
                       'HWID: "$http_x_hwid" Model: "$http_x_device_model" OS: "$http_x_device_os" Ver: "$http_x_ver_os"';
...
}
  1. Создаем конфиг, раздающий подписки и проверяющий hwid пользователей.
/etc/angie/http.d/sub.conf
server {
    listen 80;

    server_name sub.site.ru;

    access_log off;
    error_log  off;

    location / {
        return 301 https://$host$request_uri;
    }
}

acme_client sub_site_ru https://acme-v02.api.letsencrypt.org/directory;

include /etc/angie/http.d/hwid.txt;

server {
    listen 443 ssl;
#    listen [::]:443 ssl;

    http2 on;

    server_name sub.site.ru;
    acme sub_site_ru;

    ssl_certificate      $acme_cert_sub_site_ru;
    ssl_certificate_key  $acme_cert_key_sub_site_ru;
    ssl_dhparam          /etc/angie/dhparam.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ecdh_curve X25519:prime256v1:secp384r1;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;

    # Оптимизация SSL-сессий (минимизирует CPU-нагрузку на повторные хэндшейки)
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets on;

    charset utf-8;

    # Скорость отдачи статики (ядро Linux напрямую гонит данные из файла в сокет)
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;

    # Отключение просмотра директорий и скрытие версии сервера
    autoindex off;
    server_tokens off;

    # Применяем этот формат к вашему виртуальному хосту или глобально
    access_log /var/log/angie/sub_access.log vpn_log;
    error_log  /var/log/angie/sub_error.log;

    # Корневая папка для всех файлов
    root /usr/share/angie/html;

    # Запрет кеширования по умолчанию для всего контента
    expires -1;
    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;

    # Скрытие URL подписки, см. docs.incy.cc
    add_header hide-url "1" always;

    # Настройки фрагментации, см. docs.incy.cc
    add_header fragmentation-enable "1" always;
    add_header fragmentation-packets "tlshello" always;
    add_header fragmentation-length "10-30" always;
    add_header fragmentation-interval "10-30" always;

    # Настройки шумовых пакетов, см. docs.incy.cc
    add_header noises-enable "1" always;
    add_header noises-type "rand" always;
    add_header noises-packet "10-20" always;
    add_header noises-delay "10-50" always;

    location /vpn/ {
        if ($access_granted = 0) {
            return 403;
        }
    }

    # Все остальные запросы к корню закрываем
    location / {
        return 404;
    }
}

Файлик с привязками подписок и hwid

/etc/angie/http.d/hwid.txt
map $uri$http_x_hwid $access_granted {
    default 0;

    "~^/vpn/routing\.json.*$" 1; # настройки маршрутизации отдаем всем

    #"~^/vpn/e483516fc99febfb-User1\.txt026E5AB4-5A6B-BDFB-9FD2-FF0EE9562E79$" 1; #"INCY/3.3.4/windows 11 Dalvik/21.0.8+9-LTS" Model: "Windows 11 amd64" OS: "Windows 11" Ver: "10.0" -- в начале строки стоит #, правило не активно
    "~^/vpn/e483516fc99febfb-User1\.txt2543DDA5-A3B5-1FD9-00B9-D8F79F8E3D09$" 1; #"INCY/3.3.6/android Dalvik/2.1.0" Model: "Xiaomi 23113RKC6G" OS: "Android" Ver: "16"
	"~^/vpn/e483516fc99febfb-User1\.txt847FEDD1-8798-57C1-7361-8E3ABC198817$" 1; #"INCY/2.4.5/ios CFNetwork/3860.600.12 Darwin/25.5.0" Model: "iPhone 17 Pro" OS: "iOS" Ver: "26.5.2" # к одному файлу подписки может быть привязано несколько устройств
	"~^/vpn/fbb56d382bcc0a6b-User2\.txtDBBAE7228-15EB-6F03-EFBE-BA81FB81CCE8$" 1; #"INCY/2.4.8/ios CFNetwork/3860.600.12 Darwin/25.5.0" Model: "iPhone 17 Pro Max" OS: "iOS" Ver: "26.5.2"
	"~^/vpn/fba74c1258ba9487-User3\.txt.*$" 1; # это можно скачать всем без проверки hwid, иногда такое нужно )
}

После изменения этого файла: angie -s reload

  1. По умолчанию всякие html у Angie лежат тут: /usr/share/angie/html, менять не стал.
    Создаем папку vpn, туда кладем настройки роутинга.
    У меня все ноды - каскадные. Вход для пользователей на VPS в РФ. На этих VPS режется все то, что ниже прописано как direct. Ибо нефиг )
    Отключил настроенный роутинг - сам себе злобный буратино, отключай прокси, чтобы зайти в мах )))
/usr/share/angie/html/vpn/routing.json
{
    "Name": "MyVPN",
    "GlobalProxy": "true",
    "RemoteDNSType": "DoH",
    "RemoteDNSDomain": "https://cloudflare-dns.com/dns-query",
    "RemoteDNSIP": "1.1.1.1",
    "DomesticDNSType": "DoU",
    "DomesticDNSDomain": "",
    "DomesticDNSIP": "78.88.8.8",
    "Geoipurl": "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat",
    "Geositeurl": "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat",
    "DnsHosts": {
        "cloudflare-dns.com": "1.1.1.1"
    },
    "DirectSites": ["geosite:category-ru"],
    "DirectIp": ["geoip:ru", "geoip:by", "geoip:kz", "geoip:cn", "geoip:ir", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "224.0.0.0/4", "255.255.255.255"],
    "ProxySites": [],
    "ProxyIp": [],
    "BlockSites": ["geosite:category-ads"], # Именно так. С "geosite:category-ads-all" INCY на айфонах не работает.
    "BlockIp": [],
    "DomainStrategy": "IPIfNonMatch",
    "FakeDNS": "false"
}

И файлы подписок

/usr/share/angie/html/vpn/e483516fc99febfb-User1.txt
#profile-title: MyVPN - User1
#profile-update-interval: 1
#announce: 
://autorouting/onadd/https://sub.site.ru/vpn/routing.json

#Node1
vless://ba230000-5000-0000-1111-a55d1a35000@1*.1*.1*.2*:443?mode=...
...
  1. Теперь в логах angie видно hwid.
/var/log/angie/sub_access.log
9*.2*.1*.1* - - [20/Jul/2026:12:45:22 +0300] "GET /vpn/e483516fc99febfb-User1.txt HTTP/2.0" 403 189 "-" "INCY/3.3.4/windows 11 Dalvik/21.0.8+9-LTS" HWID: "026E5AB4-5A6B-BDFB-9FD2-FF0EE9562E79" Model: "Windows 11 amd64" OS: "Windows 11" Ver: "10.0" - это соответствие еще не прописано, поэтому юзеру отдается "403 Forbidden"

1*.2*.1*.1* - - [28/Jul/2026:01:20:36 +0300] "GET /vpn/fbcf1361d5601e44-User8.txt HTTP/2.0" 200 14487 "-" "INCY/2.4.7/ios CFNetwork/3860.600.12 Darwin/25.5.0" HWID: "A5834560-000-E449-F9CB-64BD63C1CBAE" Model: "iPhone 16 Pro" OS: "iOS" Ver: "26.5.2" - а "этомудала", ему можно )

Собственно, все.
Если юзер уже скачал подписку, а потом админ решил перекрыть ему доступ, то кроме правки hwid.txt надо еще поменять uuid пользователя на нодах, или вообще его там удалить.
Наверное для прокси, где сотни юзеров, вся эта схема не очень удобна, но для мелких имхо норм. Связываться с панелями я категорически не хочу, а контролировать подписки надо.

Ну, и как вишенка. С gemini сделал html обертку для редактирования hwid.txt

/etc/sudoers.d/angie-editor
www-data ALL=(ALL) NOPASSWD: /usr/sbin/angie -t, /usr/sbin/angie -s reload
# chown www-data:www-data /etc/angie/http.d/hwid.txt

# chmod 644 /etc/angie/http.d/hwid.txt

# htpasswd -c /etc/angie/.htpasswd admin

# mkdir /etc/angie/http.d/backups_hwid (бекапы hwid.txt)

# chown www-data:www-data /etc/angie/http.d/backups_hwid (Angie у меня работает под этим пользователем)
/var/www/hwid-editor/index.php
<?php
declare(strict_types=1);

define('CONFIG_FILE', '/etc/angie/http.d/hwid.txt');
define('BACKUP_DIR', '/etc/angie/http.d/backups_hwid');
define('MAX_BACKUPS', 10);

// AJAX API Handler
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    header('Content-Type: application/json');
    $input = json_decode(file_get_contents('php://input'), true);
    $action = $input['action'] ?? '';

    if ($action === 'save') {
        $rules = $input['rules'] ?? [];
        
        $lines = [];
        $lines[] = 'map $uri$http_x_hwid $access_granted {';
        $lines[] = '    default 0;';
        $lines[] = '';
        $lines[] = '    "~^/vpn/routing\.json.*$" 1;';
        $lines[] = '';

        foreach ($rules as $rule) {
            $active = (bool)($rule['active'] ?? false);
            $filename = trim((string)($rule['filename'] ?? ''));
            $hwid = trim((string)($rule['hwid'] ?? ''));
            $comment = trim((string)($rule['comment'] ?? ''));

            if ($filename === '') continue;

            $escapedFilename = str_replace('.', '\.', $filename);
            
            if ($hwid === '*') {
                $hwidPart = '.*';
            } else {
                $hwidPart = str_replace('.', '\.', $hwid);
            }

            $pattern = "~^/vpn/{$escapedFilename}{$hwidPart}$";
            
            $line = "    \"{$pattern}\" 1;";
            if ($comment !== '') {
                $line .= " #{$comment}";
            }

            if (!$active) {
                $line = "    #" . ltrim($line);
            }

            $lines[] = $line;
        }

        $lines[] = '}';
        $content = implode("\n", $lines) . "\n";

        if (!is_dir(BACKUP_DIR)) {
            mkdir(BACKUP_DIR, 0750, true);
        }
        
        if (file_exists(CONFIG_FILE)) {
            $backupFile = BACKUP_DIR . '/hwid_' . date('Ymd_His') . '.txt';
            copy(CONFIG_FILE, $backupFile);
            
            $backups = glob(BACKUP_DIR . '/hwid_*.txt');
            if (count($backups) > MAX_BACKUPS) {
                usort($backups, fn($a, $b) => filemtime($a) <=> filemtime($b));
                while (count($backups) > MAX_BACKUPS) {
                    unlink(array_shift($backups));
                }
            }
        }

        if (file_put_contents(CONFIG_FILE, $content) === false) {
            echo json_encode(['success' => false, 'error' => 'Ошибка записи файла']);
            exit;
        }

        exec('sudo /usr/sbin/angie -t 2>&1', $testOutput, $testCode);
        if ($testCode !== 0) {
            $latestBackup = glob(BACKUP_DIR . '/hwid_*.txt');
            if (!empty($latestBackup)) {
                usort($latestBackup, fn($a, $b) => filemtime($b) <=> filemtime($a));
                copy($latestBackup[0], CONFIG_FILE);
            }
            echo json_encode(['success' => false, 'error' => "Ошибка синтаксиса Angie:\n" . implode("\n", $testOutput)]);
            exit;
        }

        exec('sudo /usr/sbin/angie -s reload 2>&1', $reloadOutput, $reloadCode);
        if ($reloadCode !== 0) {
            echo json_encode(['success' => false, 'error' => "Конфиг корректен, но reload завершился ошибкой:\n" . implode("\n", $reloadOutput)]);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

    echo json_encode(['success' => false, 'error' => 'Неизвестное действие']);
    exit;
}

// Парсинг файла
$rules = [];
if (file_exists(CONFIG_FILE)) {
    $fileLines = file(CONFIG_FILE, FILE_IGNORE_NEW_LINES);
    foreach ($fileLines as $line) {
        $trimmed = trim($line);

        if ($trimmed === '' || 
            str_starts_with($trimmed, 'map ') || 
            str_starts_with($trimmed, 'default ') || 
            $trimmed === '}' || 
            str_contains($trimmed, 'routing\.json')) {
            continue;
        }

        $active = !str_starts_with($trimmed, '#');
        $cleanLine = ltrim($trimmed, '#');
        $cleanLine = trim($cleanLine);

        // Привязываемся к расширению .txt в конце имени файла
        if (preg_match('/^"~\\^\/vpn\/(.+?\\.txt)(.*?)\$"\\s+1;(?:\\s+#(.*))?$/i', $cleanLine, $matches)) {
            $filename = str_replace('\.', '.', $matches[1]);
            
            $rawHwid = $matches[2];
            if ($rawHwid === '.*' || $rawHwid === '') {
                $hwid = '*';
            } else {
                $hwid = str_replace('\-', '-', $rawHwid);
                $hwid = str_replace('\.', '.', $hwid);
            }

            $comment = isset($matches[3]) ? trim($matches[3]) : '';

            $rules[] = [
                'active' => $active,
                'filename' => $filename,
                'hwid' => $hwid,
                'comment' => $comment
            ];
        }
    }
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Angie HWID Manager</title>
    <style>
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a1a; color: #e0e0e0; margin: 20px; }
        .container { max-width: 1600px; margin: 0 auto; }
        header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 15px; }
        h1 { margin: 0; font-size: 20px; color: #fff; }
        .status { padding: 6px 12px; border-radius: 4px; font-weight: bold; font-size: 13px; }
        .status.clean { background: #2e7d32; color: #fff; }
        .status.dirty { background: #d32f2f; color: #fff; }
        
        .table-grid {
            display: grid;
            grid-template-columns: 40px var(--col-file-w, 350px) var(--col-hwid-w, 350px) var(--col-comment-w, 1fr) 90px;
            gap: 8px 10px;
            align-items: center;
        }

        .table-header {
            font-weight: bold;
            background: #2a2a2a;
            padding: 8px;
            border-radius: 4px;
            position: relative;
            user-select: none;
            display: flex;
            align-items: center;
            justify-content: space-between;
        }

        .resizer {
            position: absolute;
            right: 0;
            top: 0;
            bottom: 0;
            width: 5px;
            cursor: col-resize;
            background: #444;
        }
        .resizer:hover, .resizing {
            background: #2196f3;
        }

        .table-row-contents {
            display: contents;
        }

        .table-row-contents.disabled input[type="text"] {
            opacity: 0.4;
        }
        
        input[type="text"] { 
            background: #121212; 
            border: 1px solid #444; 
            color: #fff; 
            padding: 6px 10px; 
            border-radius: 4px; 
            font-family: monospace; 
            box-sizing: border-box;
            width: 100%;
        }
        input[type="text"]:focus { border-color: #2196f3; outline: none; }
        
        .col-center { text-align: center; }
        .col-actions { display: flex; gap: 4px; }
        
        button { background: #333; color: #fff; border: 1px solid #555; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
        button:hover { background: #444; }
        button.btn-danger { background: #c62828; border-color: #b71c1c; }
        button.btn-danger:hover { background: #d32f2f; }
        button.btn-save { background: #1565c0; border-color: #0d47a1; font-weight: bold; }
        button.btn-save:hover { background: #1976d2; }
        
        .static-block { background: #1e1e1e; padding: 10px; border-radius: 4px; font-family: monospace; color: #888; font-size: 12px; margin: 10px 0; }
        .controls { margin-top: 15px; display: flex; justify-content: space-between; }
    </style>
</head>
<body>

<div class="container" id="app">
    <header>
        <h1>Angie HWID Access Control Manager</h1>
        <div id="status" class="status clean">Изменений нет</div>
    </header>

    <div class="static-block">
        map $uri$http_x_hwid $access_granted {<br>
        &nbsp;&nbsp;&nbsp;&nbsp;default 0;<br>
        &nbsp;&nbsp;&nbsp;&nbsp;"~^/vpn/routing\.json.*$" 1;
    </div>

    <div class="table-grid" id="grid">
        <div class="table-header col-center">On</div>
        <div class="table-header">
            Имя файла
            <div class="resizer" data-col="file"></div>
        </div>
        <div class="table-header">
            HWID
            <div class="resizer" data-col="hwid"></div>
        </div>
        <div class="table-header">
            Комментарий
            <div class="resizer" data-col="comment"></div>
        </div>
        <div class="table-header">Действия</div>

        <div id="rules-rows" style="display: contents;"></div>
    </div>

    <div class="static-block">}</div>

    <div class="controls">
        <button onclick="addRule()">+ Добавить строку</button>
        <button class="btn-save" onclick="saveData()">Сохранить и применить</button>
    </div>
</div>

<script>
let rules = <?php echo json_encode($rules); ?>;
let isDirty = false;

function setDirty(state) {
    isDirty = state;
    const el = document.getElementById('status');
    if (isDirty) {
        el.textContent = 'Есть несохраненные изменения';
        el.className = 'status dirty';
    } else {
        el.textContent = 'Изменения сохранены';
        el.className = 'status clean';
    }
}

function render() {
    const container = document.getElementById('rules-rows');
    container.innerHTML = '';

    rules.forEach((rule, idx) => {
        const row = document.createElement('div');
        row.className = `table-row-contents ${rule.active ? '' : 'disabled'}`;
        
        row.innerHTML = `
            <div class="col-center">
                <input type="checkbox" ${rule.active ? 'checked' : ''} onchange="updateRule(${idx}, 'active', this.checked)">
            </div>
            <div>
                <input type="text" value="${escapeHtml(rule.filename)}" oninput="updateRule(${idx}, 'filename', this.value)">
            </div>
            <div>
                <input type="text" value="${escapeHtml(rule.hwid)}" placeholder="HWID или *" oninput="updateRule(${idx}, 'hwid', this.value)">
            </div>
            <div>
                <input type="text" value="${escapeHtml(rule.comment)}" oninput="updateRule(${idx}, 'comment', this.value)">
            </div>
            <div class="col-actions">
                <button onclick="move(${idx}, -1)" ${idx === 0 ? 'disabled' : ''}>▲</button>
                <button onclick="move(${idx}, 1)" ${idx === rules.length - 1 ? 'disabled' : ''}>▼</button>
                <button class="btn-danger" onclick="removeRule(${idx})">✕</button>
            </div>
        `;
        container.appendChild(row);
    });
}

function escapeHtml(str) {
    return (str || '').replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

function updateRule(idx, field, value) {
    rules[idx][field] = value;
    setDirty(true);
    if (field === 'active') render();
}

function addRule() {
    rules.push({ active: true, filename: '', hwid: '*', comment: '' });
    setDirty(true);
    render();
}

function removeRule(idx) {
    rules.splice(idx, 1);
    setDirty(true);
    render();
}

function move(idx, direction) {
    const target = idx + direction;
    if (target < 0 || target >= rules.length) return;
    const temp = rules[idx];
    rules[idx] = rules[target];
    rules[target] = temp;
    setDirty(true);
    render();
}

async function saveData() {
    try {
        const response = await fetch('', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ action: 'save', rules: rules })
        });
        
        const res = await response.json();
        if (res.success) {
            setDirty(false);
            alert('Конфигурация успешно обновлена и Angie перезагружен.');
        } else {
            alert('Ошибка при сохранении: ' + res.error);
        }
    } catch (e) {
        alert('Сетевая ошибка при отправке данных: ' + e.message);
    }
}

document.querySelectorAll('.resizer').forEach(resizer => {
    resizer.addEventListener('mousedown', initResize);
});

function initResize(e) {
    e.preventDefault();
    const colType = e.target.getAttribute('data-col');
    const startX = e.clientX;
    const grid = document.getElementById('grid');
    const propertyName = `--col-${colType}-w`;
    
    const currentWidth = parseInt(getComputedStyle(grid).getPropertyValue(propertyName)) || 350;

    function doResize(e) {
        const newWidth = Math.max(100, currentWidth + (e.clientX - startX));
        grid.style.setProperty(propertyName, `${newWidth}px`);
    }

    function stopResize() {
        window.removeEventListener('mousemove', doResize);
        window.removeEventListener('mouseup', stopResize);
    }

    window.addEventListener('mousemove', doResize);
    window.addEventListener('mouseup', stopResize);
}

window.addEventListener('beforeunload', (e) => {
    if (isDirty) {
        e.preventDefault();
        e.returnValue = '';
    }
});

render();
</script>
</body>
</html>
/etc/angie/http.d/hwid.conf
server {
    listen 0.0.0.0:8080;
    root /var/www/hwid-editor;
    index index.php;

    auth_basic "Restricted Access";
    auth_basic_user_file /etc/angie/.htpasswd;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

технически hwid это просто дополнительный http-заголовок, который правильные клиенты позволяют редактировать и изменять, также как и user-agent. это не защищает подписку, а сделано сервисами для защиты от домохозяек, которым надо ограничить количество оплаченных устройств. также клиенты показывают адрес подписки, ничего не мешает для этой цели использовать любой mitm-proxy, если очень приспичит, чтобы получить url и сервера.

не рекомендую полагаться на hwid. если хотите действительно чтото защитить, подумайте над ограничением паралельных запросов к подписке с разных ip адресов на конечные uuid. если клиент не игнорирует profile-update-interval в подписке, он не будет его нарушать и обращаться за подпиской чаще, чем необходимо, если чаще - то клиенту будет выдаваться ошибка, например что подписка не оплачена или просрочена и расшаренную ссылку с большей вероятностью сочтут нерабочей.

Спасибо за комментарий.
Согласен, что hwid нормальной защитой назвать сложно и нужно думать над чем-то еще.
Но у меня как раз случай подписок для “домохозяек”, которые не будут заморачиваться такими методами.
База не очень большая и денежного интереса никакого нет.
Моя цель была в том, чтобы подписки не расползались совсем бесконтрольно. Своей цели я добился и всякие аномалии стали заметны гораздо лучше.

Если уж хотите огородить себя от слива подписок — раздавайте подписки только доверенным лицам. HWID не панацея; уже существует вагон и маленькая тележка утилит, которые обходят этот лимит. Тоже самое касается и IP лимита. :slight_smile:

Мне кажется, как защита от домохозяек - отлично подходит. А вот чтобы кто-то хитрый и смекалистый не выложил в сеть (и пол-страны не начало пользоваться) - можно поставить просто лимит на трафик. Достаточно щедрый (пусть в 5 раз больше чем нужно юзеру), но если ссылка утечет в паблик - это быстро упрется в лимит.

Я неоднократно доставал конфиги из триальных белосписочных впнов в обход happ/hwid и т.д, и почему-то при использовании таких конфигов напрямую трафик в впн ботах никогда не подсчитывался.

потому что в самом ядре такой дряни как подсчет трафика не предусмотрено, для этого делается прослойка в виде дырявой панели по подписке, которая отдельно считает трафик по uuid и просто отключает из нее сервера когда все кончается или не оплачено, но сервера то сами продолжают работать.

А смысл всего этого? Не легче ли будет использовать Remnawave, где все это реализовано по-умолчанию, но с удобным интерфейсом и кучей других функций? Да и настраивается он в разы легче. Извиняюсь, если это звучит как реклама.