Implement SSH port forwarding and improve jump host UI

Port Forwarding (new):
- Add PortForward model with local/remote direction, bind/dest addresses
- Implement ForwardManager in sshclient/portforward.go with full
  bidirectional tunnel support for both local and remote forwarding
- Integrate ForwardManager into SSH client lifecycle (auto-start on
  connect, clean shutdown on close)
- Add session manager methods: ListPortForwards, StartPortForward,
  StopPortForward with event emission
- Add API surface: ListPortForwards, StartPortForward, StopPortForward
- Add validation for port-forward rules in ValidateSSHConfig
- Add port forwarding section to profile editor with direction, bind/dest
  host/port, label, and enable toggle
- Add frontend event handling for forward:started/stopped/error
- Style port-forward rows with charcoal card design matching jump hosts

Jump Host UI improvements:
- Add private key path field to jump host editor rows
- Collect privateKeyPath in form value collection
- Restyle jump host rows as two-line cards with clear field grouping
This commit is contained in:
2026-04-16 00:00:00 +01:00
parent 5e397195b9
commit 6d300f7dba
13 changed files with 664 additions and 8 deletions
+56 -2
View File
@@ -486,12 +486,66 @@
}
.jump-host-row {
display: grid;
grid-template-columns: 1fr 80px 1fr auto;
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background: var(--surface-2);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.jump-host-fields {
display: flex;
gap: var(--space-2);
align-items: center;
}
/* -----------------------------------------------------------------------
Port-forwarding rows
----------------------------------------------------------------------- */
.port-forwards-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.port-forward-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background: var(--surface-2);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.port-forward-fields {
display: flex;
gap: var(--space-2);
align-items: center;
}
.port-forward-arrow {
color: var(--text-muted);
font-size: var(--text-sm);
flex-shrink: 0;
}
.port-forward-enabled-label {
display: flex;
gap: var(--space-1);
align-items: center;
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
cursor: pointer;
}
.port-forward-enabled-label input[type="checkbox"] {
accent-color: var(--accent);
}
/* -----------------------------------------------------------------------
Payload preset rows
----------------------------------------------------------------------- */
+15
View File
@@ -180,6 +180,21 @@ class App {
);
});
// Port-forwarding events
window.runtime.EventsOn('session:forward:started', (payload) => {
const fwd = payload.forward || {};
const label = fwd.label || `${fwd.bindHost || ''}:${fwd.bindPort} → ${fwd.destHost}:${fwd.destPort}`;
this.notifications.success(`Forward started: ${label}`);
});
window.runtime.EventsOn('session:forward:stopped', (payload) => {
this.notifications.info(`Forward stopped: ${payload.forwardID}`);
});
window.runtime.EventsOn('session:forward:error', (payload) => {
this.notifications.error(`Forward error: ${payload.error}`);
});
// Telnet negotiation debug messages
window.runtime.EventsOn('session:debug', (payload) => {
console.debug('[session:debug]', payload);
+93 -5
View File
@@ -24,6 +24,7 @@ const DEFAULTS = {
keepaliveCountMax: 3,
compression: false,
jumpHosts: [],
portForwards: [],
startupCommand: '',
envVars: {},
},
@@ -373,6 +374,11 @@ class ProfileEditor {
this._renderJumpHostsList(ssh.jumpHosts || []),
], true));
// Port Forwarding
sections.push(this._renderSection('Port Forwarding', [
this._renderPortForwardsList(ssh.portForwards || []),
], true));
// Startup
sections.push(this._renderSection('Startup', [
this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, {
@@ -712,10 +718,15 @@ class ProfileEditor {
const jh = jumpHosts[i];
const row = createElement('div', 'jump-host-row');
row.innerHTML = `
<input class="form-input form-input-sm" name="jumpHost-${i}-host" value="${escapeHtml(jh.host || '')}" placeholder="Host" />
<input class="form-input form-input-sm" name="jumpHost-${i}-port" type="number" value="${jh.port || 22}" min="1" max="65535" style="width:80px" />
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || '')}" placeholder="User" />
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">&times;</button>
<div class="jump-host-fields">
<input class="form-input form-input-sm" name="jumpHost-${i}-host" value="${escapeHtml(jh.host || '')}" placeholder="Host" />
<input class="form-input form-input-sm" name="jumpHost-${i}-port" type="number" value="${jh.port || 22}" min="1" max="65535" style="width:80px" />
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || '')}" placeholder="User" />
</div>
<div class="jump-host-fields">
<input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || '')}" placeholder="Private key path (optional)" style="flex:1" />
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">&times;</button>
</div>
`;
row.querySelector('.jump-host-remove').addEventListener('click', () => {
jumpHosts.splice(i, 1);
@@ -734,6 +745,65 @@ class ProfileEditor {
return wrapper;
}
/**
* Renders the port-forwarding rules list editor.
* @param {Array<Object>} portForwards
* @returns {HTMLElement}
* @private
*/
_renderPortForwardsList(portForwards) {
const wrapper = createElement('div', 'port-forwards-list');
for (let i = 0; i < portForwards.length; i++) {
const pf = portForwards[i];
const row = createElement('div', 'port-forward-row');
row.innerHTML = `
<div class="port-forward-fields">
<select class="form-select form-select-sm" name="portFwd-${i}-direction" style="width:110px">
<option value="local" ${pf.direction === 'local' ? 'selected' : ''}>Local →</option>
<option value="remote" ${pf.direction === 'remote' ? 'selected' : ''}>← Remote</option>
</select>
<input class="form-input form-input-sm" name="portFwd-${i}-bindHost" value="${escapeHtml(pf.bindHost || '127.0.0.1')}" placeholder="Bind host" style="width:110px" />
<input class="form-input form-input-sm" name="portFwd-${i}-bindPort" type="number" value="${pf.bindPort || 0}" min="1" max="65535" placeholder="Bind port" style="width:80px" />
<span class="port-forward-arrow">⟶</span>
<input class="form-input form-input-sm" name="portFwd-${i}-destHost" value="${escapeHtml(pf.destHost || '')}" placeholder="Dest host" style="width:110px" />
<input class="form-input form-input-sm" name="portFwd-${i}-destPort" type="number" value="${pf.destPort || 0}" min="1" max="65535" placeholder="Dest port" style="width:80px" />
</div>
<div class="port-forward-fields">
<input class="form-input form-input-sm" name="portFwd-${i}-label" value="${escapeHtml(pf.label || '')}" placeholder="Label (optional)" style="flex:1" />
<label class="port-forward-enabled-label" title="Enable on connect">
<input type="checkbox" name="portFwd-${i}-enabled" ${pf.enabled !== false ? 'checked' : ''} />
<span>Active</span>
</label>
<button class="btn btn-icon btn-danger port-forward-remove" data-index="${i}" title="Remove">&times;</button>
</div>
`;
row.querySelector('.port-forward-remove').addEventListener('click', () => {
portForwards.splice(i, 1);
this.render(this._profile, this._isNew);
});
wrapper.appendChild(row);
}
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Port Forward' });
addBtn.addEventListener('click', () => {
portForwards.push({
id: generateId(),
direction: 'local',
bindHost: '127.0.0.1',
bindPort: 0,
destHost: '',
destPort: 0,
enabled: true,
label: '',
});
this.render(this._profile, this._isNew);
});
wrapper.appendChild(addBtn);
return wrapper;
}
/**
* Renders the payload-presets list editor.
* @param {Array<Object>} presets
@@ -855,11 +925,29 @@ class ProfileEditor {
username: val(`jumpHost-${ji}-username`) || '',
authMethod: 'publickey',
secretMode: 'ask-every-time',
privateKeyPath: '',
privateKeyPath: val(`jumpHost-${ji}-privateKeyPath`) || '',
});
ji++;
}
p.ssh.jumpHosts = jh;
// Port forwards
const pfs = [];
let fi = 0;
while (this.drawer.querySelector(`[name="portFwd-${fi}-direction"]`)) {
pfs.push({
id: (p.ssh.portForwards && p.ssh.portForwards[fi]?.id) || generateId(),
direction: val(`portFwd-${fi}-direction`) || 'local',
bindHost: val(`portFwd-${fi}-bindHost`) || '127.0.0.1',
bindPort: val(`portFwd-${fi}-bindPort`) || 0,
destHost: val(`portFwd-${fi}-destHost`) || '',
destPort: val(`portFwd-${fi}-destPort`) || 0,
enabled: val(`portFwd-${fi}-enabled`) !== false,
label: val(`portFwd-${fi}-label`) || '',
});
fi++;
}
p.ssh.portForwards = pfs;
}
// Telnet-specific