Add 3x-ui VLESS configs and guest access without registration.

Guests can open a public link to view or create VPN configs; panel creates VLESS clients via the 3x-ui API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 01:15:24 +03:00
co-authored by Cursor
parent 2196d127b7
commit 5386c4d40e
12 changed files with 1512 additions and 39 deletions
+210 -3
View File
@@ -45,6 +45,97 @@
</form>
</div>
<!-- BLOCK Guest Access -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('guest_settings_title') }}</h3>
<form id="guestForm">
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" id="guest_enabled" {% if settings.guest.enabled %}checked{% endif %}
onchange="document.getElementById('guestFields').style.display = this.checked ? 'block' : 'none'">
{{ _('guest_enable') }}
</label>
<div class="form-hint">{{ _('guest_enable_hint') }}</div>
</div>
<div id="guestFields" style="{% if not settings.guest.enabled %}display:none;{% endif %}">
<div class="form-group">
<label class="form-label">{{ _('guest_link_label') }}</label>
<div style="display:flex; gap: var(--space-sm);">
<input type="text" class="form-input" id="guest_link_display" readonly
value="{% if settings.guest.token %}{{ request.url.scheme }}://{{ request.url.netloc }}/guest/{{ settings.guest.token }}{% endif %}"
placeholder="{{ _('guest_link_placeholder') }}">
<button type="button" class="btn btn-secondary btn-sm" onclick="copyGuestLink()">📋</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="regenGuestToken()" title="{{ _('guest_regen_token') }}">🔄</button>
</div>
<input type="hidden" id="guest_token" value="{{ settings.guest.token or '' }}">
</div>
<div class="form-group">
<label class="form-label">{{ _('guest_user_label') }}</label>
<select class="form-select" id="guest_user_id">
<option value="">{{ _('guest_user_none') }}</option>
{% for u in users %}
<option value="{{ u.id }}" {% if settings.guest.user_id == u.id %}selected{% endif %}>
{{ u.username }} ({{ u.role }})
</option>
{% endfor %}
</select>
<div class="form-hint">{{ _('guest_user_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('guest_password_label') }}</label>
<input type="password" class="form-input" id="guest_password"
placeholder="{% if settings.guest.password_hash %}{{ _('guest_password_keep') }}{% else %}{{ _('share_password_hint') }}{% endif %}"
autocomplete="new-password">
{% if settings.guest.password_hash %}
<label style="display:flex; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;">
<input type="checkbox" id="guest_clear_password">
{{ _('guest_clear_password') }}
</label>
{% endif %}
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" id="guest_allow_create" {% if settings.guest.allow_create %}checked{% endif %}
onchange="document.getElementById('guestCreateFields').style.display = this.checked ? 'block' : 'none'">
{{ _('guest_allow_create') }}
</label>
<div class="form-hint">{{ _('guest_allow_create_hint') }}</div>
</div>
<div id="guestCreateFields"
style="{% if not settings.guest.allow_create %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md);">
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option>
{% for s in servers %}
{% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %}
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}
<option value="{{ key }}|{{ server_idx }}"
{% if settings.guest.create_protocol == key and settings.guest.create_server_id == server_idx %}selected{% endif %}>
{{ s.name or s.host }} — {{ key }}
</option>
{% endif %}
{% endfor %}
{% endfor %}
</select>
</div>
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
<label class="form-label">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="guest_create_inbound_id">
<option value="0">{{ _('xui_inbound_auto') }}</option>
</select>
</div>
</div>
</div>
</form>
</div>
<!-- BLOCK Telegram Bot -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('telegram_bot_title') }}</h3>
@@ -394,6 +485,14 @@
</label>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_inbound_label') }}</label>
<select class="form-select" name="xui_inbound_id" id="xuiInboundSelect">
<option value="0">{{ _('xui_inbound_auto') }}</option>
</select>
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
</div>
<div id="xuiAutoConnFields"
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
<div class="form-group">
@@ -616,8 +715,39 @@
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) loadXuiInbounds();
});
const xuiDefaultInboundId = {{ settings.sync.xui_inbound_id | default(0) | int }};
async function loadXuiInbounds() {
const select = document.getElementById('xuiInboundSelect');
if (!select) return;
const prev = select.value || String(xuiDefaultInboundId || '0');
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
try {
const res = await fetch('/api/settings/xui/inbounds');
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
(data.inbounds || []).forEach(ib => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
select.appendChild(opt);
});
if ([...select.options].some(o => o.value === String(prev))) {
select.value = String(prev);
}
} catch (err) {
// Keep auto option; credentials may not be saved yet
console.warn('loadXuiInbounds:', err);
}
}
if (document.querySelector('[name="xui_sync"]').checked) {
loadXuiInbounds();
}
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForSync();
@@ -897,7 +1027,8 @@
xui_api_token: syncForm.xui_api_token.value.trim(),
xui_create_conns: syncForm.xui_create_conns.checked,
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
xui_protocol: syncForm.xui_protocol.value
xui_protocol: syncForm.xui_protocol.value,
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0')
};
const res = await fetch('/api/settings/xui_sync_now', {
method: 'POST',
@@ -978,7 +1109,8 @@
xui_sync_users: syncForm.xui_sync_users.checked,
xui_create_conns: syncForm.xui_create_conns.checked,
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
xui_protocol: syncForm.xui_protocol.value
xui_protocol: syncForm.xui_protocol.value,
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0')
};
@@ -1001,11 +1133,34 @@
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
};
let createProtocol = 'xui';
let createServerId = 0;
const guestProtoRaw = document.getElementById('guest_create_protocol').value;
if (guestProtoRaw === 'xui') {
createProtocol = 'xui';
} else if (guestProtoRaw.includes('|')) {
const parts = guestProtoRaw.split('|');
createProtocol = parts[0];
createServerId = parseInt(parts[1] || '0');
}
const guest = {
enabled: document.getElementById('guest_enabled').checked,
token: document.getElementById('guest_token').value || '',
password: document.getElementById('guest_password').value || '',
clear_password: !!(document.getElementById('guest_clear_password') && document.getElementById('guest_clear_password').checked),
user_id: document.getElementById('guest_user_id').value || '',
allow_create: document.getElementById('guest_allow_create').checked,
create_protocol: createProtocol,
create_server_id: createServerId,
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0')
};
try {
const res = await fetch('/api/settings/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl })
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest })
});
if (!res.ok) throw new Error(_('error'));
showToast(_('settings_saved'), 'success');
@@ -1018,6 +1173,58 @@
}
}
function updateGuestCreateFields() {
const v = document.getElementById('guest_create_protocol')?.value;
const group = document.getElementById('guestInboundGroup');
if (group) group.style.display = (v === 'xui') ? 'block' : 'none';
}
function copyGuestLink() {
const el = document.getElementById('guest_link_display');
if (el && el.value) copyToClipboard(el.value);
}
async function regenGuestToken() {
if (!confirm(_('guest_regen_confirm'))) return;
try {
const res = await fetch('/api/settings/guest/regenerate_token', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || _('error'));
document.getElementById('guest_token').value = data.token;
const link = `${window.location.origin}/guest/${data.token}`;
document.getElementById('guest_link_display').value = link;
showToast(_('guest_token_updated'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
}
async function loadGuestInbounds() {
const select = document.getElementById('guest_create_inbound_id');
if (!select) return;
const preferred = {{ settings.guest.create_inbound_id | default(0) | int }};
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
try {
const res = await fetch('/api/settings/xui/inbounds');
const data = await res.json();
if (!res.ok) return;
(data.inbounds || []).forEach(ib => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
if (String(ib.id) === String(preferred)) opt.selected = true;
select.appendChild(opt);
});
} catch (e) { /* ignore */ }
}
if (document.getElementById('guest_allow_create')?.checked) {
loadGuestInbounds();
}
document.getElementById('guest_allow_create')?.addEventListener('change', (e) => {
if (e.target.checked) loadGuestInbounds();
});
async function toggleBot() {
const btn = document.getElementById('toggleBotBtn');
const text = document.getElementById('toggleBotBtnText');