feat: RDP slot pool — multi-user RDP with per-account containers
- New RdpSlot model (rdp_slots table): service_id, rdp_username,
rdp_password, container_name
- Each slot gets a dedicated portal-rdpslot-<slug>-<id> container with
Traefik route /rdp/<slot_id>/ and restart_policy=unless-stopped
- go_service: RDP services with slots use pool allocation — finds first
free slot (not occupied by active session), returns 503 if all busy
- session_status + session_view: handle RDPSLOT: container_id prefix
- terminate_session_record: restarts slot container in background on close
- session_redirect_url: RDPSLOT sessions redirect to /s/<id>/view
- startup_event: starts containers for all configured slots on boot
- Admin: POST /api/admin/services/{id}/rdp-slots, DELETE /api/admin/rdp-slots/{id}
- admin.html: slot management UI (list, add, delete); removed ACL exclusivity
- set_acl: removed RDP 1-user exclusivity — RDP services now assignable to many
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+60
-26
@@ -279,8 +279,6 @@
|
||||
<input id="r_slug" placeholder="Системный slug" />
|
||||
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" />
|
||||
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" />
|
||||
<input id="r_user" placeholder="Логин (опционально)" />
|
||||
<input id="r_pass" placeholder="Пароль (опционально)" type="password" />
|
||||
<input id="r_domain" placeholder="Домен (опционально)" />
|
||||
<select id="r_sec">
|
||||
<option value="">auto</option>
|
||||
@@ -342,6 +340,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rdp_slots_box" style="display:none; margin-top:1rem;">
|
||||
<div class="list-title">RDP пользователи (слоты пула)</div>
|
||||
<div class="field-help">Каждый пользователь — отдельный контейнер. Пользователи портала берут свободный слот.</div>
|
||||
<table class="admin-table" id="rdp_slots_table" style="margin-bottom:.7rem">
|
||||
<thead><tr><th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Занят</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap;">
|
||||
<input id="new_slot_user" placeholder="Логин RDP" style="max-width:160px" />
|
||||
<input id="new_slot_pass" type="password" placeholder="Пароль RDP" style="max-width:160px" />
|
||||
<button onclick="addRdpSlot()">+ Добавить слот</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="list-title">Добавить RDP</div>
|
||||
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
|
||||
@@ -350,8 +362,6 @@
|
||||
<input id="new_r_slug" placeholder="Системный slug" />
|
||||
<input id="new_r_host" placeholder="RDP host" />
|
||||
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" />
|
||||
<input id="new_r_user" placeholder="Логин (опционально)" />
|
||||
<input id="new_r_pass" placeholder="Пароль (опционально)" type="password" />
|
||||
<input id="new_r_domain" placeholder="Домен (опционально)" />
|
||||
<select id="new_r_sec">
|
||||
<option value="">auto</option>
|
||||
@@ -513,8 +523,7 @@
|
||||
const csrf = "{{ csrf_token }}";
|
||||
const aclMap = {{ acl | tojson }};
|
||||
const serviceCategoryMap = {{ service_category_map | tojson }};
|
||||
const rdpOccupiedBy = {{ rdp_occupied_by | tojson }};
|
||||
const rdpOccupiedUsername = {{ rdp_occupied_username | tojson }};
|
||||
const rdpSlotsMap = {{ rdp_slots | tojson }};
|
||||
const placeholderIcon = '/static/service-placeholder.svg';
|
||||
let activeTab = 'users';
|
||||
|
||||
@@ -687,19 +696,8 @@
|
||||
document.querySelectorAll('.acl_service').forEach((box) => {
|
||||
const sid = parseInt(box.value, 10);
|
||||
box.checked = allowed.has(sid);
|
||||
const isRdp = box.dataset.stype === 'RDP';
|
||||
const occupiedBy = rdpOccupiedBy[sid];
|
||||
const currentUserHasIt = allowed.has(sid);
|
||||
const ownerSpan = box.closest('label').querySelector('.acl-owner');
|
||||
if (isRdp && occupiedBy && occupiedBy !== userId && !currentUserHasIt) {
|
||||
box.disabled = true;
|
||||
box.closest('label').style.opacity = '0.45';
|
||||
if (ownerSpan) ownerSpan.textContent = ` (${rdpOccupiedUsername[sid]})`;
|
||||
} else {
|
||||
box.disabled = false;
|
||||
box.closest('label').style.opacity = '';
|
||||
if (ownerSpan) ownerSpan.textContent = '';
|
||||
}
|
||||
box.disabled = false;
|
||||
box.closest('label').style.opacity = '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -793,22 +791,58 @@
|
||||
function buildRdpTarget(prefix) {
|
||||
const host = (document.getElementById(`${prefix}_host`)?.value || '').trim();
|
||||
const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389';
|
||||
const user = (document.getElementById(`${prefix}_user`)?.value || '').trim();
|
||||
const pass = (document.getElementById(`${prefix}_pass`)?.value || '').trim();
|
||||
const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim();
|
||||
const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim();
|
||||
const targetInput = document.getElementById(`${prefix}_target`);
|
||||
if (!host) return (targetInput?.value || '').trim();
|
||||
const creds = user ? `${encodeURIComponent(user)}${pass ? `:${encodeURIComponent(pass)}` : ''}@` : '';
|
||||
const query = new URLSearchParams();
|
||||
if (domain) query.set('domain', domain);
|
||||
if (sec) query.set('sec', sec);
|
||||
const q = query.toString();
|
||||
const target = `rdp://${creds}${host}:${port}${q ? `?${q}` : ''}`;
|
||||
const target = `rdp://${host}:${port}${q ? `?${q}` : ''}`;
|
||||
if (targetInput) targetInput.value = target;
|
||||
return target;
|
||||
}
|
||||
|
||||
function renderRdpSlots(serviceId) {
|
||||
const box = document.getElementById('rdp_slots_box');
|
||||
const tbody = document.querySelector('#rdp_slots_table tbody');
|
||||
const slots = rdpSlotsMap[serviceId] || [];
|
||||
box.style.display = 'block';
|
||||
tbody.innerHTML = '';
|
||||
if (!slots.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>';
|
||||
return;
|
||||
}
|
||||
slots.forEach(s => {
|
||||
const statusBadge = s.running
|
||||
? '<span style="color:#4caf50">● running</span>'
|
||||
: '<span style="color:#e07b39">● stopped</span>';
|
||||
const occupiedCell = s.occupied_username
|
||||
? `<span style="color:#e07b39">${s.occupied_username}</span>`
|
||||
: '<span style="color:#888">свободен</span>';
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function addRdpSlot() {
|
||||
const serviceId = document.getElementById('r_id').value;
|
||||
if (!serviceId) return alert('Выберите RDP сервис');
|
||||
const rdp_username = document.getElementById('new_slot_user').value.trim();
|
||||
const rdp_password = document.getElementById('new_slot_pass').value.trim();
|
||||
if (!rdp_username) return alert('Введите логин RDP');
|
||||
await api(`/api/admin/services/${serviceId}/rdp-slots`, 'POST', {rdp_username, rdp_password});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function deleteRdpSlot(slotId) {
|
||||
if (!confirm('Удалить RDP слот и остановить контейнер?')) return;
|
||||
await api(`/api/admin/rdp-slots/${slotId}`, 'DELETE', {});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) {
|
||||
const cfg = parseRdpTarget(target);
|
||||
document.getElementById('r_id').value = id;
|
||||
@@ -817,8 +851,6 @@
|
||||
document.getElementById('r_target').value = target;
|
||||
document.getElementById('r_host').value = cfg.host;
|
||||
document.getElementById('r_port').value = cfg.port;
|
||||
document.getElementById('r_user').value = cfg.user;
|
||||
document.getElementById('r_pass').value = cfg.pass;
|
||||
document.getElementById('r_domain').value = cfg.domain;
|
||||
document.getElementById('r_sec').value = cfg.sec;
|
||||
document.getElementById('r_comment').value = comment || '';
|
||||
@@ -828,6 +860,7 @@
|
||||
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
|
||||
document.getElementById('r_health_box').style.display = 'block';
|
||||
markSelected('.rdp-item', 'data-service-id', id);
|
||||
renderRdpSlots(id);
|
||||
refreshSelectedServiceStatus('rdp');
|
||||
}
|
||||
|
||||
@@ -865,7 +898,8 @@
|
||||
}
|
||||
|
||||
function clearRdpForm() {
|
||||
['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = '');
|
||||
['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('rdp_slots_box').style.display = 'none';
|
||||
document.getElementById('r_sec').value = '';
|
||||
document.getElementById('r_active').value = 'true';
|
||||
setCategoryChecks('.r_cat', []);
|
||||
|
||||
Reference in New Issue
Block a user