feat: route remote terminal connections through federation proxy

- connectWebSocket() now accepts remoteId instead of sourceUrl; when
  remoteId is set the WebSocket URL is ws://host/federation/{remoteId}/terminal/ws
  (same-origin, no cross-origin connections)
- openTerminal() signature updated to accept remoteId parameter
- openSession() in app.js routes remote connect POST to
  /api/federation/{remoteId}/connect/{name} instead of the remote URL
- window._openTerminal() call passes remoteId instead of sourceUrl
- _viewingSourceUrl state replaced with _viewingRemoteId
- Tile and sidebar click handlers updated to pass remoteId
- HTML attributes changed from data-source-url to data-remote-id
- Test suite updated: removed cross-origin sourceUrl tests, added
  federation proxy path tests
This commit is contained in:
Brian Krabach
2026-04-01 13:18:10 -07:00
parent 7e28438953
commit 38300fcf79
3 changed files with 66 additions and 54 deletions
+18 -18
View File
@@ -120,7 +120,7 @@ const MOBILE_THRESHOLD = 600;
let _deviceId = ''; let _deviceId = '';
let _currentSessions = []; let _currentSessions = [];
let _viewingSession = null; let _viewingSession = null;
let _viewingSourceUrl = ''; let _viewingRemoteId = '';
let _viewMode = 'grid'; let _viewMode = 'grid';
let _lastInteractionAt = Date.now() / 1000; let _lastInteractionAt = Date.now() / 1000;
let _pollingTimer; let _pollingTimer;
@@ -484,9 +484,9 @@ function buildTileHTML(session, index, mobile) {
} }
const lastLines = allLines.slice(_lineCount).join('\n'); const lastLines = allLines.slice(_lineCount).join('\n');
const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : ''; const remoteIdAttr = session.remoteId ? ` data-remote-id="${escapeHtml(session.remoteId)}"` : '';
return ( return (
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${sourceUrlAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` + `<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` +
`<div class="tile-header">` + `<div class="tile-header">` +
`<span class="tile-name">${escapeHtml(name)}</span>` + `<span class="tile-name">${escapeHtml(name)}</span>` +
badgeHtml + badgeHtml +
@@ -543,7 +543,7 @@ function buildSidebarHTML(session, currentSession) {
const lastLines = allLines.slice(-20).join('\n'); const lastLines = allLines.slice(-20).join('\n');
return ( return (
`<article class="${classes}" data-session="${escapedName}" data-source-url="${escapeHtml(session.sourceUrl || '')}" tabindex="0" role="listitem">` + `<article class="${classes}" data-session="${escapedName}" data-remote-id="${escapeHtml(session.remoteId || '')}" tabindex="0" role="listitem">` +
`<div class="sidebar-item-header">` + `<div class="sidebar-item-header">` +
`<span class="sidebar-item-name">${escapedName}</span>` + `<span class="sidebar-item-name">${escapedName}</span>` +
`<button class="sidebar-delete" data-session="${escapedName}" aria-label="Kill session">&times;</button>` + `<button class="sidebar-delete" data-session="${escapedName}" aria-label="Kill session">&times;</button>` +
@@ -676,13 +676,13 @@ function renderSidebar(sessions, currentSession) {
list.innerHTML = html; list.innerHTML = html;
// Bind click handlers on each sidebar item, passing sourceUrl // Bind click handlers on each sidebar item, passing remoteId
if (typeof list.querySelectorAll === 'function') { if (typeof list.querySelectorAll === 'function') {
list.querySelectorAll('.sidebar-item').forEach((item) => { list.querySelectorAll('.sidebar-item').forEach((item) => {
const name = item.dataset.session; const name = item.dataset.session;
const sourceUrl = item.dataset.sourceUrl || ''; const remoteId = item.dataset.remoteId || '';
on(item, 'click', () => { on(item, 'click', () => {
if (name !== currentSession) openSession(name, { sourceUrl }); if (name !== currentSession) openSession(name, { remoteId });
}); });
}); });
} }
@@ -940,10 +940,10 @@ function renderGrid(sessions) {
// Bind interaction handlers on each tile // Bind interaction handlers on each tile
document.querySelectorAll('.session-tile').forEach(function(tile) { document.querySelectorAll('.session-tile').forEach(function(tile) {
on(tile, 'click', () => openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl || '' })); on(tile, 'click', () => openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' }));
on(tile, 'keydown', (e) => { on(tile, 'keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl || '' }); openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
} }
}); });
}); });
@@ -974,7 +974,7 @@ function _previewClickHandler(e) {
hidePreview(); hidePreview();
if (name) { if (name) {
var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; }); var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; });
openSession(name, { sourceUrl: session && session.sourceUrl || '' }); openSession(name, { remoteId: session && session.remoteId || '' });
} }
} }
@@ -1236,7 +1236,7 @@ function updateFaviconBadge() {
async function openSession(name, opts = {}) { async function openSession(name, opts = {}) {
hidePreview(); hidePreview();
_viewingSession = name; _viewingSession = name;
_viewingSourceUrl = opts.sourceUrl || ''; _viewingRemoteId = opts.remoteId || '';
_viewMode = 'fullscreen'; _viewMode = 'fullscreen';
// Pre-render sidebar with current sessions before first poll tick // Pre-render sidebar with current sessions before first poll tick
@@ -1303,11 +1303,11 @@ async function openSession(name, opts = {}) {
if (fab) fab.classList.add('hidden'); if (fab) fab.classList.add('hidden');
// Always spawn ttyd for this session — ensures correct session after service restart or page restore // Always spawn ttyd for this session — ensures correct session after service restart or page restore
var _sourceUrl = opts.sourceUrl || ''; var _remoteId = opts.remoteId || '';
try { try {
if (_sourceUrl) { if (_remoteId) {
var remoteConnectUrl = _sourceUrl.replace(/\/+$/, '') + '/api/sessions/' + encodeURIComponent(name) + '/connect'; // Remote session: route connect POST through same-origin federation proxy
await fetch(remoteConnectUrl, { method: 'POST', credentials: 'include' }); await api('POST', '/api/federation/' + encodeURIComponent(_remoteId) + '/connect/' + encodeURIComponent(name));
} else { } else {
await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect'); await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect');
} }
@@ -1320,7 +1320,7 @@ async function openSession(name, opts = {}) {
await animDone; await animDone;
// Mount terminal NOW — /connect has completed, new ttyd is serving the correct session // Mount terminal NOW — /connect has completed, new ttyd is serving the correct session
if (window._openTerminal) window._openTerminal(name, _sourceUrl); if (window._openTerminal) window._openTerminal(name, _remoteId);
} }
/** /**
@@ -1334,10 +1334,10 @@ function closeSession() {
if (window._closeTerminal) window._closeTerminal(); if (window._closeTerminal) window._closeTerminal();
// Fire-and-forget DELETE — skip for remote sessions (they don't need to know we stopped watching) // Fire-and-forget DELETE — skip for remote sessions (they don't need to know we stopped watching)
if (!_viewingSourceUrl) { if (!_viewingRemoteId) {
api('DELETE', '/api/sessions/current').catch(function() {}); api('DELETE', '/api/sessions/current').catch(function() {});
} }
_viewingSourceUrl = ''; _viewingRemoteId = '';
const expanded = $('view-expanded'); const expanded = $('view-expanded');
const overview = $('view-overview'); const overview = $('view-overview');
+13 -10
View File
@@ -12,14 +12,17 @@ let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for
// ─── Forward declarations ───────────────────────────────────────────────────── // ─── Forward declarations ─────────────────────────────────────────────────────
function connectWebSocket(name, sourceUrl) { function connectWebSocket(name, remoteId) {
// Always connect to the same origin — remote sessions route through the
// federation proxy (ws://host/federation/{remoteId}/terminal/ws) so that
// no cross-origin WebSocket connections are made from the browser.
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url; var url;
if (sourceUrl) { if (remoteId) {
// Remote session: derive WS URL from the source's HTTP URL // Remote session via federation proxy — same origin, different path
url = sourceUrl.replace(/^http/, 'ws').replace(/\/+$/, '') + '/terminal/ws'; url = proto + '//' + location.host + '/federation/' + remoteId + '/terminal/ws';
} else { } else {
// Local session: same origin // Local session: same origin
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
url = proto + '//' + location.host + '/terminal/ws'; url = proto + '//' + location.host + '/terminal/ws';
} }
const reconnectOverlay = document.getElementById('reconnect-overlay'); const reconnectOverlay = document.getElementById('reconnect-overlay');
@@ -225,11 +228,11 @@ function createTerminal() {
/** /**
* Open a terminal session inside #terminal-container. * Open a terminal session inside #terminal-container.
* @param {string} sessionName * @param {string} sessionName
* @param {string} [sourceUrl] Optional HTTP URL of the remote muxplex instance. * @param {string} [remoteId] Optional federation remote ID.
* When provided, the WebSocket connects to that remote host instead of the * When provided, the WebSocket connects via the federation proxy path
* current page origin. * ws://host/federation/{remoteId}/terminal/ws (same origin, no cross-origin).
*/ */
function openTerminal(sessionName, sourceUrl) { function openTerminal(sessionName, remoteId) {
// Null _currentSession first so any in-flight close handler on the old WS won't // Null _currentSession first so any in-flight close handler on the old WS won't
// schedule a reconnect (it checks `if (!_currentSession) return;`). // schedule a reconnect (it checks `if (!_currentSession) return;`).
_currentSession = null; _currentSession = null;
@@ -276,7 +279,7 @@ function openTerminal(sessionName, sourceUrl) {
}); });
} }
connectWebSocket(sessionName, sourceUrl); connectWebSocket(sessionName, remoteId);
initVisualViewport(); /* defined in Task 14 */ initVisualViewport(); /* defined in Task 14 */
} }
+35 -26
View File
@@ -719,30 +719,48 @@ test('terminal is auto-focused when WebSocket opens', () => {
'_term.focus() should be called exactly once when the WebSocket open event fires'); '_term.focus() should be called exactly once when the WebSocket open event fires');
}); });
// --- sourceUrl / remote WebSocket tests ------------------------------------ // --- remoteId / federation proxy WebSocket tests ----------------------------
test('connectWebSocket uses remote origin when sourceUrl is provided', () => { test('connectWebSocket uses federation proxy path when remoteId is provided', () => {
const t = loadTerminal(); const t = loadTerminal();
const orig = globalThis.setTimeout; const orig = globalThis.setTimeout;
globalThis.setTimeout = (_fn, _ms) => 0; globalThis.setTimeout = (_fn, _ms) => 0;
t.openTerminal('remote-session', 'http://work:8088'); t.openTerminal('remote-session', 'fed-abc123');
globalThis.setTimeout = orig;
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.strictEqual(
t.capturedWsUrl,
'ws://localhost/federation/fed-abc123/terminal/ws',
`WebSocket URL should be ws://localhost/federation/fed-abc123/terminal/ws, got: ${t.capturedWsUrl}`,
);
});
test('connectWebSocket uses same-origin for remote sessions (no cross-origin WS)', () => {
const t = loadTerminal();
const orig = globalThis.setTimeout;
globalThis.setTimeout = (_fn, _ms) => 0;
t.openTerminal('remote-session', 'remote-device-1');
globalThis.setTimeout = orig; globalThis.setTimeout = orig;
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.ok( assert.ok(
t.capturedWsUrl.startsWith('ws://work:8088/'), t.capturedWsUrl.startsWith('ws://localhost/'),
`WebSocket URL should start with ws://work:8088/, got: ${t.capturedWsUrl}`, `WebSocket URL for remote session must stay on same origin (ws://localhost/), got: ${t.capturedWsUrl}`,
); );
assert.ok( assert.ok(
t.capturedWsUrl.endsWith('/terminal/ws'), t.capturedWsUrl.includes('/federation/remote-device-1/terminal/ws'),
`WebSocket URL should end with /terminal/ws, got: ${t.capturedWsUrl}`, `WebSocket URL must include federation path, got: ${t.capturedWsUrl}`,
); );
}); });
test('connectWebSocket uses local origin when sourceUrl is empty string', () => { test('connectWebSocket uses local origin when remoteId is empty string', () => {
const t = loadTerminal(); const t = loadTerminal();
const orig = globalThis.setTimeout; const orig = globalThis.setTimeout;
@@ -755,11 +773,15 @@ test('connectWebSocket uses local origin when sourceUrl is empty string', () =>
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.ok( assert.ok(
t.capturedWsUrl.includes('localhost'), t.capturedWsUrl.includes('localhost'),
`WebSocket URL should include localhost for empty sourceUrl, got: ${t.capturedWsUrl}`, `WebSocket URL should include localhost for empty remoteId, got: ${t.capturedWsUrl}`,
);
assert.ok(
!t.capturedWsUrl.includes('/federation/'),
`WebSocket URL must NOT include /federation/ for empty remoteId, got: ${t.capturedWsUrl}`,
); );
}); });
test('connectWebSocket uses local origin when sourceUrl is undefined', () => { test('connectWebSocket uses local origin when remoteId is undefined', () => {
const t = loadTerminal(); const t = loadTerminal();
const orig = globalThis.setTimeout; const orig = globalThis.setTimeout;
@@ -772,24 +794,11 @@ test('connectWebSocket uses local origin when sourceUrl is undefined', () => {
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.ok( assert.ok(
t.capturedWsUrl.includes('localhost'), t.capturedWsUrl.includes('localhost'),
`WebSocket URL should include localhost when sourceUrl is undefined, got: ${t.capturedWsUrl}`, `WebSocket URL should include localhost when remoteId is undefined, got: ${t.capturedWsUrl}`,
); );
});
test('connectWebSocket converts https sourceUrl to wss protocol', () => {
const t = loadTerminal();
const orig = globalThis.setTimeout;
globalThis.setTimeout = (_fn, _ms) => 0;
t.openTerminal('secure-session', 'https://devserver:8088');
globalThis.setTimeout = orig;
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.ok( assert.ok(
t.capturedWsUrl.startsWith('wss://devserver:8088/'), !t.capturedWsUrl.includes('/federation/'),
`WebSocket URL should start with wss://devserver:8088/, got: ${t.capturedWsUrl}`, `WebSocket URL must NOT include /federation/ when remoteId is undefined, got: ${t.capturedWsUrl}`,
); );
}); });