mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-26 13:39:54 +00:00
6fbf590c1d
pages: bake the caller's role into the served page, version the logo
The header flashed on every navigation: gated links (Sessions, Reports,
Tokens, Admin) were hidden in the markup and shown after /api/me
answered, the logo was revalidated on each page load, and the nav
differed between pages, so switching tabs reflowed the header.
Serve each page in five variants, one per role level, that differ only
in a data-role-level attribute on <html>; optional auth now runs on the
page routes so a logged-in request gets its variant with the navigation
already right on first paint. The ETag is per variant. The logo joins
scripts and stylesheets in content-addressed, immutable caching.
Part of #254
pages: one navigation for every page, gated by role in CSS
Every page carries the same eight links in the same order, including
the Credentials entry (which opens the dialog on the connections page
and deep-links to it from elsewhere), with data-min-role on the gated
ones instead of inline display:none. rustguac.css shows them from the
data-role-level the server writes on <html>; the pages set that
attribute themselves from /api/me for API-key users, whose page GET
carries no credential. Admin-only controls follow the same gate, so
nothing pops in after the first paint. Recordings shows "host (Tenant)"
entry names as the host over a quieter tenant line.
Part of #254
tests: one navigation on every page, gated by data-role-level
(cherry picked from commit 5eed92c6ec)
733 lines
37 KiB
HTML
733 lines
37 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Sessions</title>
|
|
<link rel="stylesheet" href="/rustguac.css">
|
|
<style>
|
|
form {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
padding: var(--s-4) var(--s-5);
|
|
border-radius: var(--radius);
|
|
max-width: 480px;
|
|
margin: var(--s-4) 0;
|
|
}
|
|
form input {
|
|
display: block;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
form button {
|
|
margin-top: var(--s-4);
|
|
min-width: 140px;
|
|
}
|
|
#sessions { margin-top: var(--s-5); }
|
|
#sessions a { text-decoration: none; font-size: var(--fz-sm); }
|
|
#sessions a:hover { text-decoration: underline; }
|
|
#sessions a.shadow-link {
|
|
color: var(--status-pending);
|
|
font-weight: bold;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
#sessions a.shadow-link:hover { color: var(--primary); }
|
|
#sessions a.shadow-link-busy { opacity: 0.6; pointer-events: none; }
|
|
/* Owner column cues so admins can scan own vs others' sessions */
|
|
#sessions td.owner-self { color: var(--text-muted); }
|
|
#sessions td.owner-other { color: var(--accent); font-weight: bold; }
|
|
.btn-add-hop {
|
|
background: transparent;
|
|
color: var(--accent);
|
|
border: 1px solid var(--border);
|
|
font-size: var(--fz-sm);
|
|
height: var(--ctl-sm);
|
|
padding: 0 var(--s-3);
|
|
margin-top: var(--s-2);
|
|
font-weight: normal;
|
|
}
|
|
.btn-add-hop:hover { background: var(--surface); border-color: var(--accent); }
|
|
</style>
|
|
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
|
|
</head>
|
|
<body>
|
|
<div class="brand-row">
|
|
<img id="site-logo" src="/logo.svg" alt="">
|
|
<h1>rustguac</h1>
|
|
</div>
|
|
<nav>
|
|
<a href="/connections.html">Connections</a>
|
|
<a href="/sessions.html" class="active" id="sessions-link" data-min-role="poweruser">Sessions</a>
|
|
<a href="/recordings.html">Recordings</a>
|
|
<a href="/reports.html" id="reports-link" data-min-role="poweruser">Reports</a>
|
|
<a href="/docs.html">Docs</a>
|
|
<a href="/tokens.html" id="tokens-link" data-min-role="operator">Tokens</a>
|
|
<a href="/admin.html" id="admin-link" data-min-role="admin">Admin</a>
|
|
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">🔑 Credentials</a>
|
|
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">⚙ Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
|
|
</nav>
|
|
|
|
<div id="adhoc-notice" style="display:none;background:var(--surface);padding:1em 1.5em;border-radius:6px;max-width:400px;margin:1.5em 0;color:var(--text-muted);">
|
|
Use the <a href="/connections.html">Connections</a> to connect to saved entries.
|
|
</div>
|
|
|
|
<form id="session-form" style="display:none">
|
|
<div style="display:flex;align-items:center;gap:0.5em;cursor:pointer" id="new-session-toggle">
|
|
<span id="new-session-arrow" style="color:var(--accent)">▶</span>
|
|
<strong style="color:var(--accent)">New Ad-Hoc Session</strong>
|
|
</div>
|
|
<div id="new-session-fields" style="display:none;margin-top:0.8em">
|
|
<label>Type
|
|
<select id="session-type" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;">
|
|
<option value="ssh" selected>SSH</option>
|
|
<option value="rdp">RDP</option>
|
|
<option value="vnc">VNC</option>
|
|
<option value="web">Web</option>
|
|
<option value="vdi">VDI (Docker)</option>
|
|
</select>
|
|
</label>
|
|
<div id="ssh-fields">
|
|
<label>Host
|
|
<input type="text" id="hostname" placeholder="192.168.1.1">
|
|
</label>
|
|
<label>Port
|
|
<input type="number" id="port" value="22">
|
|
</label>
|
|
<label>Username (optional — SSH will prompt if empty)
|
|
<input type="text" id="username">
|
|
</label>
|
|
<label>Password (optional — SSH will prompt if empty)
|
|
<input type="password" id="password">
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="generate-keypair" style="display:inline;width:auto;margin-right:0.4em"> Generate ephemeral SSH keypair
|
|
</label>
|
|
<div id="private-key-field">
|
|
<label>Private key (optional — paste OpenSSH PEM)
|
|
<textarea id="private-key" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:0.85em;border-radius:3px;box-sizing:border-box;resize:vertical;"></textarea>
|
|
</label>
|
|
<div style="color:var(--primary);font-size:0.8em;margin-top:0.3em;">Warning: Do not paste important production keys here. Use ephemeral or test keys only.</div>
|
|
</div>
|
|
</div>
|
|
<div id="rdp-fields" style="display:none">
|
|
<label>Host
|
|
<input type="text" id="rdp-hostname" placeholder="192.168.1.1">
|
|
</label>
|
|
<label>Port
|
|
<input type="number" id="rdp-port" value="3389">
|
|
</label>
|
|
<label>Username
|
|
<input type="text" id="rdp-username">
|
|
</label>
|
|
<label>Password
|
|
<input type="password" id="rdp-password">
|
|
</label>
|
|
<label>Domain (optional)
|
|
<input type="text" id="rdp-domain">
|
|
</label>
|
|
<label>Security
|
|
<select id="rdp-security" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;">
|
|
<option value="" selected>Default</option>
|
|
<option value="tls">TLS</option>
|
|
<option value="nla">NLA</option>
|
|
<option value="rdp">RDP</option>
|
|
</select>
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="rdp-ignore-cert" style="display:inline;width:auto;margin-right:0.4em"> Ignore certificate errors
|
|
</label>
|
|
</div>
|
|
<div id="vnc-fields" style="display:none">
|
|
<label>Host
|
|
<input type="text" id="vnc-hostname" placeholder="192.168.1.1">
|
|
</label>
|
|
<label>Port
|
|
<input type="number" id="vnc-port" value="5900">
|
|
</label>
|
|
<label>Username (optional)
|
|
<input type="text" id="vnc-username">
|
|
</label>
|
|
<label>Password (optional)
|
|
<input type="password" id="vnc-password">
|
|
</label>
|
|
</div>
|
|
<div id="web-fields" style="display:none">
|
|
<label>URL
|
|
<input type="text" id="url" placeholder="https://example.com">
|
|
</label>
|
|
</div>
|
|
<div id="vdi-fields" style="display:none">
|
|
<label>Container Image
|
|
<input type="text" id="vdi-image" placeholder="e.g. myregistry/xrdp-desktop:latest">
|
|
</label>
|
|
<div style="color:var(--text-muted);font-size:0.8em;margin-top:0.3em">Docker image with xrdp on port 3389. Container is ephemeral.</div>
|
|
</div>
|
|
<div id="jump-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="jump-toggle">
|
|
<span id="jump-arrow">▶</span> SSH Tunnel / Jump Hosts
|
|
</label>
|
|
<div id="jump-fields" style="display:none">
|
|
<div style="color:#555;font-size:0.8em;margin-bottom:0.5em">Route through one or more SSH bastion hosts (multi-hop).</div>
|
|
<div id="flow-diagram" class="flow-diagram"></div>
|
|
<div id="hops-list"></div>
|
|
<button type="button" class="btn-add-hop" id="add-hop-btn">+ Add Jump Host</button>
|
|
</div>
|
|
</div>
|
|
<label>Banner message (shown to user before session starts)
|
|
<input type="text" id="banner" placeholder="e.g. Please enter your credentials when prompted">
|
|
</label>
|
|
<button type="submit" id="connect-btn">Connect</button>
|
|
<div id="error"></div>
|
|
</div>
|
|
</form>
|
|
|
|
<div id="sessions">
|
|
<div class="section-head">
|
|
<strong>Sessions</strong>
|
|
<span class="section-count" id="session-counts"></span>
|
|
</div>
|
|
<table>
|
|
<thead><tr>
|
|
<th>ID</th><th>Type</th><th>Host</th><th>User</th><th>Owner</th><th>Status</th><th>Viewers</th><th></th><th></th>
|
|
</tr></thead>
|
|
<tbody id="session-list"></tbody>
|
|
</table>
|
|
<p id="session-empty" class="empty" style="display:none"></p>
|
|
</div>
|
|
|
|
<script>
|
|
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
|
var _themePresets={},_adminPreset='aurora';
|
|
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
|
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'aurora';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
|
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
|
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
|
if(d.site_title){document.title=d.site_title+' - Sessions';document.querySelector('h1').textContent=d.site_title;}
|
|
initTheme(d.theme);
|
|
});
|
|
var apiKey = sessionStorage.getItem('rustguac_api_key');
|
|
|
|
// Check auth and role
|
|
var roleLevel = { admin: 4, poweruser: 3, operator: 2, viewer: 1 };
|
|
var myName = null;
|
|
var myRole = null;
|
|
function checkRole() {
|
|
fetch('/api/me', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (res.status === 401 || res.status === 403) { window.location.href = '/'; return; }
|
|
if (!res.ok) return;
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
if (!data) return;
|
|
myName = data.name || null;
|
|
myRole = data.role || null;
|
|
var level = roleLevel[data.role] || 0;
|
|
// Reconcile the server-written nav gate (see rustguac.css): the page
|
|
// GET carried no API key, so key users arrive on the anonymous variant.
|
|
document.documentElement.setAttribute('data-role-level', String((typeof apiKey !== 'undefined' && apiKey) ? 4 : level));
|
|
if (level < 3) {
|
|
window.location.href = '/connections.html';
|
|
return;
|
|
}
|
|
if (data.role === 'admin') {
|
|
document.getElementById('admin-link').style.display = '';
|
|
}
|
|
if (level >= 2) {
|
|
document.getElementById('tokens-link').style.display = '';
|
|
}
|
|
if (level >= 3) {
|
|
// poweruser+ can create ad-hoc sessions
|
|
document.getElementById('session-form').style.display = '';
|
|
document.getElementById('reports-link').style.display = '';
|
|
}
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
if (apiKey) {
|
|
// A key is never on the page GET, so the server served the anonymous variant.
|
|
document.documentElement.setAttribute('data-role-level', '4');
|
|
// API key users are always admin
|
|
myRole = 'admin';
|
|
document.getElementById('session-form').style.display = '';
|
|
document.getElementById('admin-link').style.display = '';
|
|
document.getElementById('tokens-link').style.display = '';
|
|
document.getElementById('reports-link').style.display = '';
|
|
// Fetch /api/me anyway to learn our display_name for own-session checks.
|
|
fetch('/api/me', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(r) { return r.ok ? r.json() : null; })
|
|
.then(function(d) { if (d && d.name) myName = d.name; })
|
|
.catch(function() {});
|
|
} else {
|
|
checkRole();
|
|
}
|
|
|
|
document.getElementById('logout-item').addEventListener('click', function() {
|
|
sessionStorage.removeItem('rustguac_api_key');
|
|
// Also clear server session cookie via logout endpoint
|
|
fetch('/auth/logout', { credentials: 'same-origin' })
|
|
.finally(function() { window.location.href = '/'; });
|
|
});
|
|
|
|
function apiHeaders(extra) {
|
|
var h = {};
|
|
if (apiKey) h['Authorization'] = 'Bearer ' + apiKey;
|
|
if (extra) { for (var k in extra) h[k] = extra[k]; }
|
|
return h;
|
|
}
|
|
|
|
var form = document.getElementById('session-form');
|
|
var errorEl = document.getElementById('error');
|
|
var btn = document.getElementById('connect-btn');
|
|
var sessionType = document.getElementById('session-type');
|
|
var sshFields = document.getElementById('ssh-fields');
|
|
var rdpFields = document.getElementById('rdp-fields');
|
|
var vncFields = document.getElementById('vnc-fields');
|
|
var webFields = document.getElementById('web-fields');
|
|
var jumpSection = document.getElementById('jump-section');
|
|
|
|
var generateKeypair = document.getElementById('generate-keypair');
|
|
var privateKeyField = document.getElementById('private-key-field');
|
|
var passwordField = document.getElementById('password');
|
|
|
|
generateKeypair.addEventListener('change', function() {
|
|
if (generateKeypair.checked) {
|
|
privateKeyField.style.display = 'none';
|
|
passwordField.disabled = true;
|
|
passwordField.value = '';
|
|
} else {
|
|
privateKeyField.style.display = '';
|
|
passwordField.disabled = false;
|
|
}
|
|
});
|
|
|
|
var vdiFields = document.getElementById('vdi-fields');
|
|
|
|
sessionType.addEventListener('change', function() {
|
|
sshFields.style.display = 'none';
|
|
rdpFields.style.display = 'none';
|
|
vncFields.style.display = 'none';
|
|
webFields.style.display = 'none';
|
|
vdiFields.style.display = 'none';
|
|
var v = sessionType.value;
|
|
if (v === 'ssh') sshFields.style.display = '';
|
|
else if (v === 'rdp') rdpFields.style.display = '';
|
|
else if (v === 'vnc') vncFields.style.display = '';
|
|
else if (v === 'web') webFields.style.display = '';
|
|
else if (v === 'vdi') vdiFields.style.display = '';
|
|
// Hide jump hosts for VDI (local containers)
|
|
jumpSection.style.display = v === 'vdi' ? 'none' : '';
|
|
});
|
|
|
|
// Collapsible "New Ad-Hoc Session" section
|
|
document.getElementById('new-session-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('new-session-fields');
|
|
var arrow = document.getElementById('new-session-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// Show jump section on initial load (default is SSH)
|
|
jumpSection.style.display = '';
|
|
|
|
document.getElementById('jump-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('jump-fields');
|
|
var arrow = document.getElementById('jump-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// ── Escape helpers ──
|
|
function escapeHtml(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
function escapeAttr(s) {
|
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ── Multi-hop management ──
|
|
var hopList = []; // Array of {hostname, port, username, password, private_key, expanded}
|
|
|
|
function renderHops() {
|
|
var container = document.getElementById('hops-list');
|
|
container.innerHTML = '';
|
|
hopList.forEach(function(hop, i) {
|
|
var card = document.createElement('div');
|
|
card.className = 'hop-card';
|
|
|
|
// Header
|
|
var header = document.createElement('div');
|
|
header.className = 'hop-card-header';
|
|
|
|
var num = document.createElement('span');
|
|
num.className = 'hop-num';
|
|
num.textContent = '#' + (i + 1);
|
|
header.appendChild(num);
|
|
|
|
var hostWrap = document.createElement('span');
|
|
hostWrap.className = 'hop-hostname';
|
|
var hostInput = document.createElement('input');
|
|
hostInput.type = 'text';
|
|
hostInput.placeholder = 'bastion.example.com';
|
|
hostInput.value = hop.hostname || '';
|
|
hostInput.setAttribute('data-hop', i);
|
|
hostInput.setAttribute('data-field', 'hostname');
|
|
hostInput.addEventListener('input', onHopFieldChange);
|
|
hostInput.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
hostWrap.appendChild(hostInput);
|
|
header.appendChild(hostWrap);
|
|
|
|
var toggle = document.createElement('button');
|
|
toggle.type = 'button';
|
|
toggle.className = 'hop-remove';
|
|
toggle.textContent = hop.expanded ? 'collapse' : 'expand';
|
|
toggle.setAttribute('data-hop', i);
|
|
toggle.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
hopList[i].expanded = !hopList[i].expanded;
|
|
renderHops();
|
|
});
|
|
header.appendChild(toggle);
|
|
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.className = 'hop-remove';
|
|
removeBtn.textContent = 'remove';
|
|
removeBtn.setAttribute('data-hop', i);
|
|
removeBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
hopList.splice(i, 1);
|
|
renderHops();
|
|
updateFlowDiagram();
|
|
});
|
|
header.appendChild(removeBtn);
|
|
|
|
card.appendChild(header);
|
|
|
|
// Body (collapsible)
|
|
var body = document.createElement('div');
|
|
body.className = 'hop-card-body' + (hop.expanded ? ' expanded' : '');
|
|
|
|
body.innerHTML =
|
|
'<label>SSH Port<input type="number" data-hop="' + i + '" data-field="port" value="' + (hop.port || 22) + '"></label>' +
|
|
'<label>Username<input type="text" data-hop="' + i + '" data-field="username" value="' + escapeAttr(hop.username || '') + '"></label>' +
|
|
'<label>Password<input type="password" data-hop="' + i + '" data-field="password" value="' + escapeAttr(hop.password || '') + '"></label>' +
|
|
'<label>Private key (PEM)<textarea data-hop="' + i + '" data-field="private_key" rows="2" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----">' + escapeHtml(hop.private_key || '') + '</textarea></label>';
|
|
|
|
var inputs = body.querySelectorAll('input, textarea');
|
|
for (var j = 0; j < inputs.length; j++) {
|
|
inputs[j].addEventListener('input', onHopFieldChange);
|
|
}
|
|
|
|
card.appendChild(body);
|
|
container.appendChild(card);
|
|
});
|
|
updateFlowDiagram();
|
|
}
|
|
|
|
function onHopFieldChange(e) {
|
|
var idx = parseInt(e.target.getAttribute('data-hop'));
|
|
var field = e.target.getAttribute('data-field');
|
|
if (field === 'port') {
|
|
hopList[idx][field] = parseInt(e.target.value) || 22;
|
|
} else {
|
|
hopList[idx][field] = e.target.value;
|
|
}
|
|
if (field === 'hostname') updateFlowDiagram();
|
|
}
|
|
|
|
function updateFlowDiagram() {
|
|
var diag = document.getElementById('flow-diagram');
|
|
if (!diag) return;
|
|
var html = '<span class="flow-node flow-node-you">You</span>';
|
|
hopList.forEach(function(hop) {
|
|
var label = hop.hostname || '???';
|
|
html += '<span class="flow-arrow">→</span><span class="flow-node flow-node-hop">' + escapeHtml(label) + '</span>';
|
|
});
|
|
// Add target
|
|
var type = sessionType.value;
|
|
var targetHost = '';
|
|
var targetPort = '';
|
|
var typeLabel = type.toUpperCase();
|
|
if (type === 'ssh') {
|
|
targetHost = document.getElementById('hostname').value || '???';
|
|
targetPort = document.getElementById('port').value || '22';
|
|
} else if (type === 'rdp') {
|
|
targetHost = document.getElementById('rdp-hostname').value || '???';
|
|
targetPort = document.getElementById('rdp-port').value || '3389';
|
|
} else if (type === 'vnc') {
|
|
targetHost = document.getElementById('vnc-hostname').value || '???';
|
|
targetPort = document.getElementById('vnc-port').value || '5900';
|
|
} else if (type === 'web') {
|
|
// Parse host:port from URL
|
|
try {
|
|
var u = new URL(document.getElementById('url').value);
|
|
targetHost = u.hostname || '???';
|
|
targetPort = u.port || (u.protocol === 'https:' ? '443' : '80');
|
|
} catch(e) {
|
|
targetHost = '???';
|
|
targetPort = '80';
|
|
}
|
|
}
|
|
if (targetHost) {
|
|
html += '<span class="flow-arrow">→</span><span class="flow-node flow-node-target">' + escapeHtml(targetHost) + ':' + escapeHtml(targetPort) + ' ' + typeLabel + '</span>';
|
|
}
|
|
// Show URL rewrite warning for web sessions with hops
|
|
var warn = '';
|
|
if (type === 'web' && hopList.length > 0) {
|
|
warn = '<div style="color:var(--primary);font-size:0.8em;margin-top:0.4em">Note: The URL will be rewritten to 127.0.0.1:{tunnel_port} inside the headless browser. TLS certificate errors are expected if the target uses HTTPS.</div>';
|
|
}
|
|
diag.innerHTML = hopList.length > 0 ? html + warn : '';
|
|
}
|
|
|
|
document.getElementById('add-hop-btn').addEventListener('click', function() {
|
|
hopList.push({ hostname: '', port: 22, username: '', password: '', private_key: '', expanded: true });
|
|
renderHops();
|
|
});
|
|
|
|
form.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
errorEl.textContent = '';
|
|
btn.disabled = true;
|
|
btn.textContent = 'Connecting...';
|
|
|
|
var body = {};
|
|
if (sessionType.value === 'vdi') {
|
|
body.session_type = 'vdi';
|
|
body.container_image = document.getElementById('vdi-image').value;
|
|
} else if (sessionType.value === 'web') {
|
|
body.session_type = 'web';
|
|
body.url = document.getElementById('url').value;
|
|
} else if (sessionType.value === 'vnc') {
|
|
body.session_type = 'vnc';
|
|
body.hostname = document.getElementById('vnc-hostname').value;
|
|
body.port = parseInt(document.getElementById('vnc-port').value) || 5900;
|
|
var un = document.getElementById('vnc-username').value;
|
|
if (un) body.username = un;
|
|
var pw = document.getElementById('vnc-password').value;
|
|
if (pw) body.password = pw;
|
|
} else if (sessionType.value === 'rdp') {
|
|
body.session_type = 'rdp';
|
|
body.hostname = document.getElementById('rdp-hostname').value;
|
|
body.port = parseInt(document.getElementById('rdp-port').value) || 3389;
|
|
var un = document.getElementById('rdp-username').value;
|
|
if (un) body.username = un;
|
|
var pw = document.getElementById('rdp-password').value;
|
|
if (pw) body.password = pw;
|
|
var dom = document.getElementById('rdp-domain').value;
|
|
if (dom) body.domain = dom;
|
|
var sec = document.getElementById('rdp-security').value;
|
|
if (sec) body.security = sec;
|
|
if (document.getElementById('rdp-ignore-cert').checked) body.ignore_cert = true;
|
|
} else {
|
|
body.session_type = 'ssh';
|
|
body.hostname = document.getElementById('hostname').value;
|
|
body.port = parseInt(document.getElementById('port').value) || 22;
|
|
var un = document.getElementById('username').value;
|
|
if (un) body.username = un;
|
|
if (document.getElementById('generate-keypair').checked) {
|
|
body.generate_keypair = true;
|
|
} else {
|
|
var pw = document.getElementById('password').value;
|
|
if (pw) body.password = pw;
|
|
var pk = document.getElementById('private-key').value.trim();
|
|
if (pk) body.private_key = pk;
|
|
}
|
|
}
|
|
// Jump hosts (multi-hop, all session types)
|
|
var hops = hopList.filter(function(h) { return h.hostname && h.hostname.trim(); })
|
|
.map(function(h) {
|
|
var hop = { hostname: h.hostname.trim(), port: h.port || 22, username: h.username || '' };
|
|
if (h.password) hop.password = h.password;
|
|
if (h.private_key) hop.private_key = h.private_key;
|
|
return hop;
|
|
});
|
|
if (hops.length > 0) body.jump_hosts = hops;
|
|
var banner = document.getElementById('banner').value;
|
|
if (banner) body.banner = banner;
|
|
// Send browser dimensions so RDP sessions start at correct resolution
|
|
var bw = window.innerWidth, bh = window.innerHeight;
|
|
if (bw > 0 && bh > 0) {
|
|
body.width = bw;
|
|
body.height = bh;
|
|
body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
|
|
}
|
|
fetch('/api/sessions', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body),
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
window.open(data.client_url, '_blank');
|
|
hopList = [];
|
|
renderHops();
|
|
refreshSessions();
|
|
})
|
|
.catch(function(err) {
|
|
errorEl.textContent = err.message;
|
|
})
|
|
.finally(function() {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Connect';
|
|
});
|
|
});
|
|
|
|
|
|
function h(tag, text, attrs) {
|
|
var el = document.createElement(tag);
|
|
if (text != null) el.textContent = text;
|
|
if (attrs) for (var k in attrs) el.setAttribute(k, attrs[k]);
|
|
return el;
|
|
}
|
|
|
|
function refreshSessions() {
|
|
// Admins get everyone's sessions; non-admins see only their own (see #102).
|
|
fetch('/api/sessions?all=true', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) throw new Error('auth');
|
|
return res.json();
|
|
})
|
|
.then(function(sessions) {
|
|
var tbody = document.getElementById('session-list');
|
|
var empty = document.getElementById('session-empty');
|
|
var counts = document.getElementById('session-counts');
|
|
tbody.innerHTML = '';
|
|
|
|
var activeCount = 0;
|
|
var ownActiveCount = 0;
|
|
|
|
sessions.forEach(function(s) {
|
|
var tr = document.createElement('tr');
|
|
var shortId = s.session_id.substring(0, 8);
|
|
var hostCol = s.session_type === 'web' ? (s.url || '') : s.hostname;
|
|
var isActive = s.status === 'active';
|
|
var isOwn = myName && s.created_by === myName;
|
|
var canShadow = isActive && !isOwn && myRole === 'admin';
|
|
|
|
if (isActive) activeCount++;
|
|
if (isActive && isOwn) ownActiveCount++;
|
|
if (!isOwn) tr.classList.add('row-other');
|
|
|
|
var tdId = document.createElement('td');
|
|
var idLink = h('a', shortId, { href: s.client_url, target: '_blank' });
|
|
tdId.appendChild(idLink);
|
|
tr.appendChild(tdId);
|
|
|
|
tr.appendChild(h('td', s.session_type));
|
|
tr.appendChild(h('td', hostCol));
|
|
tr.appendChild(h('td', s.username));
|
|
tr.appendChild(h('td', s.created_by || '', { 'class': isOwn ? 'owner-self' : 'owner-other' }));
|
|
tr.appendChild(h('td', s.status, { 'class': 'status-' + s.status }));
|
|
tr.appendChild(h('td', String(s.active_connections)));
|
|
|
|
var tdOpen = document.createElement('td');
|
|
if (isActive && isOwn) {
|
|
tdOpen.appendChild(h('a', 'open', { href: s.client_url, target: '_blank' }));
|
|
} else if (canShadow) {
|
|
tdOpen.appendChild(h('a', 'watch', {
|
|
href: '#',
|
|
'class': 'shadow-link',
|
|
'data-shadow': s.session_id,
|
|
'data-read-only': 'true',
|
|
title: 'Join this session as a view-only viewer (10 min token, audit-logged)'
|
|
}));
|
|
tdOpen.appendChild(document.createTextNode(' '));
|
|
tdOpen.appendChild(h('a', 'shadow', {
|
|
href: '#',
|
|
'class': 'shadow-link',
|
|
'data-shadow': s.session_id,
|
|
title: 'Join this session with keyboard and mouse control (10 min token, audit-logged)'
|
|
}));
|
|
}
|
|
tr.appendChild(tdOpen);
|
|
|
|
// Delete: owner can always delete own; admin can delete any.
|
|
var canDelete = isOwn || myRole === 'admin';
|
|
var tdDel = document.createElement('td');
|
|
if (canDelete) {
|
|
tdDel.appendChild(h('button', 'delete', { 'class': 'btn-small', 'data-delete': s.session_id }));
|
|
}
|
|
tr.appendChild(tdDel);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
// Count indicator: "12 active · 3 yours" for admins seeing others,
|
|
// "3 active" when caller only sees their own.
|
|
if (activeCount === 0) {
|
|
empty.style.display = '';
|
|
empty.textContent = myRole === 'admin'
|
|
? 'No active sessions.'
|
|
: 'No active sessions. Use Connections to connect to a saved entry, or the form above to start an ad-hoc session.';
|
|
counts.textContent = '';
|
|
} else {
|
|
empty.style.display = 'none';
|
|
if (myRole === 'admin' && activeCount !== ownActiveCount) {
|
|
counts.textContent = activeCount + ' active · ' + ownActiveCount + ' yours';
|
|
} else {
|
|
counts.textContent = activeCount + ' active';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById('session-list').addEventListener('click', function(e) {
|
|
if (e.target.getAttribute('data-delete')) {
|
|
var id = e.target.getAttribute('data-delete');
|
|
fetch('/api/sessions/' + id, { method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function() { refreshSessions(); });
|
|
}
|
|
if (e.target.getAttribute('data-shadow')) {
|
|
e.preventDefault();
|
|
var id = e.target.getAttribute('data-shadow');
|
|
var link = e.target;
|
|
var originalText = link.textContent;
|
|
link.textContent = 'minting...';
|
|
link.classList.add('shadow-link-busy');
|
|
var shadowHeaders = apiHeaders();
|
|
shadowHeaders['Content-Type'] = 'application/json';
|
|
fetch('/api/sessions/' + id + '/shadow', {
|
|
method: 'POST',
|
|
headers: shadowHeaders,
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ read_only: link.getAttribute('data-read-only') === 'true' })
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'shadow failed'); });
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
if (data.url) window.open(data.url, '_blank');
|
|
})
|
|
.catch(function(err) {
|
|
document.getElementById('error').textContent = 'Shadow failed: ' + err.message;
|
|
})
|
|
.finally(function() {
|
|
link.textContent = originalText;
|
|
link.classList.remove('shadow-link-busy');
|
|
});
|
|
}
|
|
});
|
|
|
|
refreshSessions();
|
|
setInterval(refreshSessions, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|