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:
+18
-18
@@ -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">×</button>` +
|
`<button class="sidebar-delete" data-session="${escapedName}" aria-label="Kill session">×</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');
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user