mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-26 13:39:54 +00:00
32581f30ae
The '/' search box leaves the connections folder header for a bigger,
centred box in the brand row, inline with the logo. Results open in a dim,
blurred overlay over an untouched folder view: a centred panel with the
summary, the Type and Folder facets and the results table (fixed columns,
one line per cell, folder shown by display name over its key, Connect, new
window and open-folder gathered into one actions cell). Focusing the box or
pressing / opens it and lists every connection the user can see; typing
narrows it; Escape from anywhere, a mousedown outside the box and panel, or
opening a folder closes it (an open facet menu takes the first Escape).
static/search.js is the single implementation, used on every page
including connections, which dropped ~16k of duplicated markup, CSS and JS;
the styles live once in rustguac.css so the results look identical
everywhere. The index is cached in sessionStorage and only re-fetched when
the address book's version changes, so moving between pages is instant; the
connections page keeps the folder-specific hooks it needs (rgSelectFolder
selects a folder in place, rgSearchIndex feeds the credentials banner) and
opening a folder from another page navigates to connections with
class, and the module stands down cleanly on a page with no brand row.
Connections also gets a more generous content gutter, the folder action
buttons move down to the filter row beside Type, the folder title is inset
to align with the table, and the Connect button keeps its size and spins
in place instead of growing to 'Connecting...'.
Part of #254
tests: the global search overlay on every page
search: keep page modals above the brand-row search box
The global search lifts the brand row to z-index 300 so the box is not
dimmed by its own results overlay (200), but the page modals sat at 100, so
the search input showed through the ad-hoc session dialog and could still
be focused. Modal overlays now sit at 1000, above the search chrome and
below the themed dialogs (4000) and the embedded session (5000). A spec
pins it on the ad-hoc and folder modals.
search: never on the login page, nor for anyone the server does not recognise
The global search was included on index.html, so the login page carried
the search box, and because the index was cached in sessionStorage it
kept answering with the whole address book after logout, or to the next
login in the same tab.
- index.html no longer loads search.js at all.
- search.js only renders for an operator or above: the role the server
bakes on <html data-role-level> decides on first paint, and an API-key
user (served the anonymous variant) is confirmed through /api/me first.
Anonymous or viewer: nothing injected, cache swept.
- The cached index is keyed to the identity /api/me returns; a cache left
by a different login is dropped rather than shown.
- A 401/403 from the index or version endpoints removes the box, the
overlay and the cache; the logout link clears the cache before the
login page shows.
e2e: login page has no box and never loads search.js; anonymous and
viewer callers get nothing and a stale cache is wiped; a different
login's cache is dropped; the same login keeps its cache across pages;
logout and expiry forget it.
(cherry picked from commit b8c5c0f9ee)
802 lines
42 KiB
HTML
802 lines
42 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); }
|
|
.adhoc-modal form { background: transparent; border: 0; padding: 0; margin: 0; max-width: none; width: auto; }
|
|
.adhoc-modal #new-session-fields { margin-top: 0; }
|
|
</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"><span class="nav-key">🔑</span> 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>
|
|
|
|
<div id="adhoc-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="adhoc-modal-title">
|
|
<div class="modal adhoc-modal">
|
|
<h3 id="adhoc-modal-title">New Ad-Hoc Session</h3>
|
|
<p class="modal-hint">A one-off session to any host. Nothing is saved; use Connections for entries you come back to.</p>
|
|
<form id="session-form">
|
|
<div id="new-session-fields">
|
|
<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>
|
|
<div class="modal-actions">
|
|
<button type="submit" id="connect-btn">Connect</button>
|
|
<button type="button" class="btn-cancel" id="adhoc-cancel">Cancel</button>
|
|
</div>
|
|
<div id="error"></div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="sessions">
|
|
<div class="list-tools">
|
|
<input type="text" id="session-search" class="list-search" placeholder="Search sessions (user, host, owner, ID…)">
|
|
<div id="session-filters"></div>
|
|
<button type="button" class="btn-add list-action" id="new-session-btn" style="display:none" title="Start a one-off session to any host. Nothing is saved.">⚡ New ad-hoc session</button>
|
|
</div>
|
|
<span class="section-count" id="session-counts" style="display:none"></span>
|
|
<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 src="/filters.js"></script>
|
|
<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('new-session-btn').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('new-session-btn').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' : '';
|
|
});
|
|
|
|
// "New ad-hoc session" opens the form in a modal; Cancel, Escape or
|
|
// a click on the backdrop closes it. The form itself is unchanged.
|
|
var adhocModal = document.getElementById('adhoc-modal');
|
|
function openAdhoc() {
|
|
adhocModal.classList.add('active');
|
|
document.getElementById('error').textContent = '';
|
|
var first = document.getElementById('session-type');
|
|
if (first) { try { first.focus(); } catch (e) {} }
|
|
}
|
|
function closeAdhoc() { adhocModal.classList.remove('active'); }
|
|
document.getElementById('new-session-btn').addEventListener('click', openAdhoc);
|
|
document.getElementById('adhoc-cancel').addEventListener('click', closeAdhoc);
|
|
adhocModal.addEventListener('mousedown', function(e) { if (e.target === adhocModal) closeAdhoc(); });
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && adhocModal.classList.contains('active')) closeAdhoc();
|
|
});
|
|
|
|
// 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) {
|
|
lastSessionList = sessions;
|
|
renderSessionList(sessions);
|
|
});
|
|
}
|
|
|
|
// Shared facet filters (filters.js): Type, Status, Owner. Selections
|
|
// persist in sessionStorage for this tab. Counts in the header stay
|
|
// for the full list; the table shows the filtered one.
|
|
var lastSessionList = null;
|
|
var sessionFilters = rgFilters.create({
|
|
mount: document.getElementById('session-filters'),
|
|
id: 'sessions',
|
|
facets: [
|
|
{ key: 'type', label: 'Type', value: function(s) { return s.session_type; },
|
|
render: function(v) { return '<span class="type-badge type-' + v.replace(/[^a-z0-9-]/gi, '') + '">' + v.replace(/[<>&]/g, '') + '</span>'; } },
|
|
{ key: 'user', label: 'User', value: function(s) { return s.username; } },
|
|
{ key: 'owner', label: 'Owner', value: function(s) { return s.created_by; } },
|
|
{ key: 'status', label: 'Status', value: function(s) { return s.status; } }
|
|
],
|
|
onChange: function() { if (lastSessionList) renderSessionList(lastSessionList); }
|
|
});
|
|
function sessionSearchQuery() {
|
|
return (document.getElementById('session-search').value || '').toLowerCase();
|
|
}
|
|
document.getElementById('session-search').addEventListener('input', function() {
|
|
if (lastSessionList) renderSessionList(lastSessionList);
|
|
});
|
|
|
|
function renderSessionList(allSessions) {
|
|
sessionFilters.update(allSessions);
|
|
var sessions = sessionFilters.apply(allSessions);
|
|
var q = sessionSearchQuery();
|
|
if (q) {
|
|
sessions = sessions.filter(function(s) {
|
|
var host = (s.session_type === 'web' ? s.url : s.hostname) || '';
|
|
return (s.username || '').toLowerCase().indexOf(q) >= 0
|
|
|| host.toLowerCase().indexOf(q) >= 0
|
|
|| (s.created_by || '').toLowerCase().indexOf(q) >= 0
|
|
|| (s.session_id || '').toLowerCase().indexOf(q) >= 0
|
|
|| (s.session_type || '').toLowerCase().indexOf(q) >= 0;
|
|
});
|
|
}
|
|
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);
|
|
|
|
var tdType = document.createElement('td');
|
|
tdType.appendChild(h('span', s.session_type, { 'class': 'type-badge type-' + s.session_type }));
|
|
tr.appendChild(tdType);
|
|
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 (sessions.length === 0 && allSessions.length > 0) {
|
|
empty.style.display = '';
|
|
empty.textContent = 'No sessions match the current filters.';
|
|
} else 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.';
|
|
} else {
|
|
empty.style.display = 'none';
|
|
}
|
|
// Header counts describe the whole list, not the filtered view.
|
|
var totalActive = 0, ownActive = 0;
|
|
allSessions.forEach(function(s) {
|
|
if (s.status !== 'active') return;
|
|
totalActive++;
|
|
if (myName && s.created_by === myName) ownActive++;
|
|
});
|
|
if (totalActive === 0) {
|
|
counts.textContent = '';
|
|
} else if (myRole === 'admin' && totalActive !== ownActive) {
|
|
counts.textContent = totalActive + ' active · ' + ownActive + ' yours' + (sessionFilters.active() ? ' · ' + sessions.length + ' shown' : '');
|
|
} else {
|
|
counts.textContent = totalActive + ' active' + (sessionFilters.active() ? ' · ' + sessions.length + ' shown' : '');
|
|
}
|
|
}
|
|
|
|
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>
|
|
<script src="/search.js"></script>
|
|
</body>
|
|
</html>
|