3x-ui: create VLESS/WireGuard clients and reload available inbounds

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-25 22:09:59 +03:00
co-authored by Cursor
parent b67e7b80ba
commit 1626c16df0
4 changed files with 525 additions and 136 deletions
+184 -38
View File
@@ -8,6 +8,7 @@
</div>
<div class="actions">
<a class="btn btn-sm btn-ghost" href="/admin/xui">Назад</a>
<button class="btn btn-sm btn-ghost" type="button" id="btn-reload-inbounds">Загрузить inbound’ы</button>
<form method="post" action="/admin/xui/{{ panel.id }}/check" class="inline-form">
<button class="btn btn-sm btn-accent" type="submit">Обновить API</button>
</form>
@@ -19,6 +20,8 @@
<div class="flash flash-error">{{ flash[6:] }}</div>
{% elif flash == 'client_created' %}
<div class="flash">Клиент создан в 3x-ui</div>
{% elif flash == 'ok' %}
<div class="flash">Данные обновлены</div>
{% endif %}
{% endif %}
@@ -26,53 +29,122 @@
<div class="flash flash-error">{{ error }}</div>
{% endif %}
{% if created %}
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head"><strong>Новый клиент ({{ created.protocol }})</strong></div>
<div class="panel-body stack">
<p style="margin:0"><strong>{{ created.email }}</strong> → inbound #{{ created.inbound_id }}</p>
{% if created.protocol == 'vless' %}
{% if created.uuid %}<p class="muted" style="margin:0">UUID: <code>{{ created.uuid }}</code>{% if created.flow %} · flow: {{ created.flow }}{% endif %}</p>{% endif %}
{% if created.links %}
{% for link in created.links %}
<pre class="mono">{{ link }}</pre>
{% endfor %}
{% else %}
<p class="muted">Ссылка vless:// появится в 3x-ui (Clients → Copy URL).</p>
{% endif %}
{% elif created.protocol == 'wireguard' %}
<p class="muted" style="margin:0">Ключи клиента (конфиг соберите в 3x-ui или ниже):</p>
<pre class="mono">PrivateKey = {{ created.privateKey }}
PublicKey = {{ created.publicKey }}
{% if created.inbound %}Endpoint port = {{ created.inbound.port }}{% endif %}</pre>
{% endif %}
</div>
</div>
{% endif %}
{% if data %}
<div class="stats">
<div class="stat">
<div class="label">Inbounds</div>
<div class="label">Все inbound’ы</div>
<div class="value">{{ data.inbounds|length }}</div>
</div>
<div class="stat">
<div class="label">Clients</div>
<div class="value">{{ data.clients|length }}</div>
<div class="label">VLESS</div>
<div class="value">{{ data.vless_inbounds|length }}</div>
</div>
<div class="stat">
<div class="label">Xray</div>
<div class="value" style="font-size:1.1rem">
{% if data.status and data.status.xray %}
{% if data.status.xray.state %}running{% else %}stopped{% endif %}
{% else %}—{% endif %}
<div class="label">WireGuard</div>
<div class="value">{{ data.wireguard_inbounds|length }}</div>
</div>
</div>
<div class="detail-grid" style="margin-bottom:1rem">
<div class="panel">
<div class="panel-head"><strong>Создать VLESS клиента</strong></div>
<div class="panel-body">
<form method="post" action="/admin/xui/{{ panel.id }}/clients" class="stack">
<input type="hidden" name="protocol" value="vless" />
<label>Email / имя
<input name="email" placeholder="user-vless" required />
</label>
<label>Inbound VLESS
<select name="inbound_id" id="select-vless" required>
{% for ib in data.vless_inbounds %}
<option value="{{ ib.id }}">#{{ ib.id }} {{ ib.remark or 'vless' }} :{{ ib.port }}{% if ib.enable is defined and not ib.enable %} (off){% endif %}</option>
{% else %}
<option value="" disabled selected>Нет VLESS inbound’ов — нажмите «Загрузить»</option>
{% endfor %}
</select>
</label>
<label>Flow (пусто = авто vision для Reality/TLS)
<input name="flow" placeholder="xtls-rprx-vision" />
</label>
<div class="form-grid-2">
<label>Лимит GB
<input type="number" name="total_gb" value="0" min="0" />
</label>
<label>Limit IP
<input type="number" name="limit_ip" value="0" min="0" />
</label>
</div>
<label>Комментарий
<input name="comment" />
</label>
<button class="btn btn-accent" type="submit" {% if not data.vless_inbounds %}disabled{% endif %}>Создать VLESS</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-head"><strong>Создать WireGuard клиента</strong></div>
<div class="panel-body">
<form method="post" action="/admin/xui/{{ panel.id }}/clients" class="stack">
<input type="hidden" name="protocol" value="wireguard" />
<label>Email / имя
<input name="email" placeholder="user-wg" required />
</label>
<label>Inbound WireGuard
<select name="inbound_id" id="select-wg" required>
{% for ib in data.wireguard_inbounds %}
<option value="{{ ib.id }}">#{{ ib.id }} {{ ib.remark or 'wireguard' }} :{{ ib.port }}{% if ib.enable is defined and not ib.enable %} (off){% endif %}</option>
{% else %}
<option value="" disabled selected>Нет WireGuard inbound’ов — нажмите «Загрузить»</option>
{% endfor %}
</select>
</label>
<div class="form-grid-2">
<label>Лимит GB
<input type="number" name="total_gb" value="0" min="0" />
</label>
<label>Limit IP
<input type="number" name="limit_ip" value="0" min="0" />
</label>
</div>
<label>Комментарий
<input name="comment" />
</label>
<button class="btn btn-accent" type="submit" {% if not data.wireguard_inbounds %}disabled{% endif %}>Создать WireGuard</button>
</form>
</div>
</div>
</div>
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head"><strong>Добавить клиента через API</strong></div>
<div class="panel-body">
<form method="post" action="/admin/xui/{{ panel.id }}/clients" class="form-grid">
<label>Email / имя
<input name="email" placeholder="user1" required />
</label>
<label>Inbound
<select name="inbound_id" required>
{% for ib in data.inbounds %}
<option value="{{ ib.id }}">#{{ ib.id }} {{ ib.remark or ib.protocol }} :{{ ib.port }}</option>
{% endfor %}
</select>
</label>
<button class="btn btn-accent" type="submit">Создать</button>
<label>Лимит GB (0 = безлимит)
<input type="number" name="total_gb" value="0" min="0" />
</label>
<label>Limit IP
<input type="number" name="limit_ip" value="0" min="0" />
</label>
</form>
<div class="panel-head">
<strong>Доступные inbound’ы (VLESS / WireGuard)</strong>
<span class="muted" id="inbounds-status"></span>
</div>
</div>
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head"><strong>Inbounds</strong></div>
<table>
<thead>
<tr>
@@ -83,17 +155,17 @@
<th>Enable</th>
</tr>
</thead>
<tbody>
{% for ib in data.inbounds %}
<tbody id="inbounds-table-body">
{% for ib in data.available_inbounds %}
<tr>
<td>{{ ib.id }}</td>
<td>{{ ib.remark or '—' }}</td>
<td><span class="badge badge-proto">{{ ib.protocol }}</span></td>
<td>{{ ib.port }}</td>
<td>{% if ib.enable %}<span class="badge badge-ok">on</span>{% else %}<span class="badge badge-off">off</span>{% endif %}</td>
<td>{% if ib.enable is not defined or ib.enable %}<span class="badge badge-ok">on</span>{% else %}<span class="badge badge-off">off</span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Нет inbound’ов</td></tr>
<tr><td colspan="5" class="muted">Нет VLESS/WireGuard inbound’ов</td></tr>
{% endfor %}
</tbody>
</table>
@@ -123,10 +195,84 @@
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">Нет клиентов или endpoint /clients/list недоступен</td></tr>
<tr><td colspan="4" class="muted">Нет клиентов</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(function () {
const panelId = {{ panel.id }};
const btn = document.getElementById('btn-reload-inbounds');
const statusEl = document.getElementById('inbounds-status');
const tbody = document.getElementById('inbounds-table-body');
const selectVless = document.getElementById('select-vless');
const selectWg = document.getElementById('select-wg');
function fillSelect(select, items, emptyLabel) {
if (!select) return;
select.innerHTML = '';
if (!items.length) {
const opt = document.createElement('option');
opt.disabled = true;
opt.selected = true;
opt.value = '';
opt.textContent = emptyLabel;
select.appendChild(opt);
return;
}
items.forEach((ib) => {
const opt = document.createElement('option');
opt.value = ib.id;
const off = (ib.enable === false) ? ' (off)' : '';
opt.textContent = `#${ib.id} ${ib.remark || ib.protocol} :${ib.port}${off}`;
select.appendChild(opt);
});
}
async function reloadInbounds() {
statusEl.textContent = 'загрузка…';
btn.disabled = true;
try {
const res = await fetch(`/admin/xui/${panelId}/inbounds`);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'fail');
const items = data.obj || [];
const vless = items.filter((i) => String(i.protocol).toLowerCase() === 'vless');
const wg = items.filter((i) => String(i.protocol).toLowerCase() === 'wireguard');
fillSelect(selectVless, vless, 'Нет VLESS inbound’ов');
fillSelect(selectWg, wg, 'Нет WireGuard inbound’ов');
const vlessBtn = selectVless && selectVless.closest('form').querySelector('button[type=submit]');
const wgBtn = selectWg && selectWg.closest('form').querySelector('button[type=submit]');
if (vlessBtn) vlessBtn.disabled = vless.length === 0;
if (wgBtn) wgBtn.disabled = wg.length === 0;
tbody.innerHTML = '';
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">Нет VLESS/WireGuard inbound’ов</td></tr>';
} else {
items.forEach((ib) => {
const tr = document.createElement('tr');
const on = ib.enable === false
? '<span class="badge badge-off">off</span>'
: '<span class="badge badge-ok">on</span>';
tr.innerHTML = `<td>${ib.id}</td><td>${ib.remark || '—'}</td><td><span class="badge badge-proto">${ib.protocol}</span></td><td>${ib.port}</td><td>${on}</td>`;
tbody.appendChild(tr);
});
}
statusEl.textContent = `загружено: ${items.length} (VLESS ${vless.length}, WG ${wg.length})`;
} catch (e) {
statusEl.textContent = 'ошибка: ' + e.message;
} finally {
btn.disabled = false;
}
}
btn.addEventListener('click', reloadInbounds);
})();
</script>
{% endif %}
{% endblock %}