Add auto-login scripts (expect-style send/wait sequences)

Implement expect-style auto-login scripts for SSH and Telnet connections.
Each script is an ordered list of 'wait' (match text in output) and 'send'
(write text + newline) steps that execute sequentially after connection.

Backend:
- Add LoginScriptStep model (type, value, timeout) to models.go
- Add loginScripts field to SSHConfig and TelnetConfig
- Add loginBuf/loginDone on Session struct for output buffering
- Hook onData callbacks to feed login buffer during script execution
- executeLoginScripts() runs steps in a goroutine after connect
- waitForOutput() polls session buffer with configurable timeout

Frontend:
- Add _renderLoginScriptSection() to profile editor
- Interactive step list: add/remove/reorder wait and send steps
- Type selector, value input, timeout for wait steps
- Collect loginScripts as JSON from hidden field
- CSS for login script rows, actions, add buttons
This commit is contained in:
2026-04-17 00:00:00 +01:00
parent 63c27470f6
commit 1463394cbb
4 changed files with 459 additions and 8 deletions
+99
View File
@@ -571,3 +571,102 @@
padding: var(--space-1) var(--space-2);
height: 30px;
}
/* -----------------------------------------------------------------------
Login Script Editor — expect-style send/wait steps
----------------------------------------------------------------------- */
.form-help {
font-size: var(--text-xs);
color: var(--text-muted);
margin-bottom: var(--space-3);
line-height: 1.5;
}
.login-script-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.login-script-empty {
font-size: var(--text-xs);
color: var(--text-muted);
font-style: italic;
padding: var(--space-2) 0;
}
.login-script-row {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: var(--space-2);
}
.login-script-type {
width: 90px;
min-width: 90px;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-value {
flex: 1;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-timeout {
width: 60px;
min-width: 60px;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-timeout-label {
font-size: var(--text-xs);
color: var(--text-muted);
min-width: 10px;
}
.login-script-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.login-script-btn {
background: var(--bg-elevated);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xs);
width: 24px;
height: 24px;
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.login-script-btn:hover {
background: var(--bg-surface);
color: var(--text-primary);
}
.login-script-btn-remove:hover {
background: var(--danger);
color: #fff;
}
.login-script-add-row {
display: flex;
gap: var(--space-2);
}
+201
View File
@@ -27,6 +27,7 @@ const DEFAULTS = {
portForwards: [],
startupCommand: '',
envVars: {},
loginScripts: [],
},
},
telnet: {
@@ -39,6 +40,7 @@ const DEFAULTS = {
encoding: 'utf-8',
negotiationDebug: false,
lineMode: false,
loginScripts: [],
},
},
raw: {
@@ -390,6 +392,9 @@ class ProfileEditor {
}),
]));
// Login Scripts (expect-style)
sections.push(this._renderLoginScriptSection('ssh', ssh.loginScripts || []));
// SSH Agent
sections.push(this._renderSection('SSH Agent', [
this._field('toggle', 'ssh.agentAuth', 'Use SSH Agent for Authentication', ssh.agentAuth, {
@@ -451,6 +456,9 @@ class ProfileEditor {
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
], true));
// Login Scripts (expect-style)
sections.push(this._renderLoginScriptSection('telnet', tel.loginScripts || []));
sections.push(this._renderSection('Reconnection', [
this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, {
help: 'Automatically reconnect with exponential backoff when the connection drops',
@@ -571,6 +579,175 @@ class ProfileEditor {
* @returns {HTMLElement}
* @private
*/
/**
* Renders the login script section (expect-style send/wait steps).
* @param {string} prefix - 'ssh' or 'telnet'
* @param {Array} steps - Array of {type, value, timeout}
* @returns {HTMLElement}
* @private
*/
_renderLoginScriptSection(prefix, steps) {
const section = createElement('div', 'form-section');
const header = createElement('div', 'form-section-header');
let collapsed = true;
header.innerHTML = `
<span class="form-section-chevron">▸</span>
<span class="form-section-title">Login Script</span>
`;
const content = createElement('div', 'form-section-content');
content.style.display = 'none';
const helpText = createElement('div', 'form-help', {
textContent: 'Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection.',
});
content.appendChild(helpText);
// Hidden input to hold JSON
const hiddenInput = createElement('input', '', {
type: 'hidden',
id: `field-${prefix}.loginScripts`,
dataset: { field: `${prefix}.loginScripts` },
});
content.appendChild(hiddenInput);
// List container
const listContainer = createElement('div', 'login-script-list');
content.appendChild(listContainer);
// Working copy
let currentSteps = JSON.parse(JSON.stringify(steps || []));
const renderSteps = () => {
listContainer.innerHTML = '';
hiddenInput.value = JSON.stringify(currentSteps);
if (currentSteps.length === 0) {
const empty = createElement('div', 'login-script-empty', {
textContent: 'No login script steps configured.',
});
listContainer.appendChild(empty);
}
currentSteps.forEach((step, idx) => {
const row = createElement('div', 'login-script-row');
const typeSelect = createElement('select', 'form-input login-script-type');
['wait', 'send'].forEach(t => {
const opt = createElement('option', '', { value: t, textContent: t === 'wait' ? 'Wait for' : 'Send' });
if (t === step.type) opt.selected = true;
typeSelect.appendChild(opt);
});
typeSelect.addEventListener('change', () => {
currentSteps[idx].type = typeSelect.value;
hiddenInput.value = JSON.stringify(currentSteps);
renderSteps();
});
const valueInput = createElement('input', 'form-input login-script-value', {
type: 'text',
value: step.value || '',
placeholder: step.type === 'wait' ? 'Text to match...' : 'Text to send...',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: false,
});
valueInput.addEventListener('input', () => {
currentSteps[idx].value = valueInput.value;
hiddenInput.value = JSON.stringify(currentSteps);
});
row.appendChild(typeSelect);
row.appendChild(valueInput);
// Timeout field for wait steps
if (step.type === 'wait') {
const timeoutInput = createElement('input', 'form-input login-script-timeout', {
type: 'number',
value: step.timeout || 10,
min: 1,
max: 120,
title: 'Timeout (seconds)',
});
timeoutInput.style.width = '60px';
timeoutInput.addEventListener('input', () => {
currentSteps[idx].timeout = parseInt(timeoutInput.value, 10) || 10;
hiddenInput.value = JSON.stringify(currentSteps);
});
const timeoutLabel = createElement('span', 'login-script-timeout-label', { textContent: 's' });
row.appendChild(timeoutInput);
row.appendChild(timeoutLabel);
}
// Move up/down/remove buttons
const actions = createElement('div', 'login-script-actions');
if (idx > 0) {
const upBtn = createElement('button', 'login-script-btn', { textContent: '▲', title: 'Move up', type: 'button' });
upBtn.addEventListener('mousedown', e => e.preventDefault());
upBtn.addEventListener('click', () => {
[currentSteps[idx - 1], currentSteps[idx]] = [currentSteps[idx], currentSteps[idx - 1]];
renderSteps();
});
actions.appendChild(upBtn);
}
if (idx < currentSteps.length - 1) {
const downBtn = createElement('button', 'login-script-btn', { textContent: '▼', title: 'Move down', type: 'button' });
downBtn.addEventListener('mousedown', e => e.preventDefault());
downBtn.addEventListener('click', () => {
[currentSteps[idx], currentSteps[idx + 1]] = [currentSteps[idx + 1], currentSteps[idx]];
renderSteps();
});
actions.appendChild(downBtn);
}
const removeBtn = createElement('button', 'login-script-btn login-script-btn-remove', { textContent: '✕', title: 'Remove', type: 'button' });
removeBtn.addEventListener('mousedown', e => e.preventDefault());
removeBtn.addEventListener('click', () => {
currentSteps.splice(idx, 1);
renderSteps();
});
actions.appendChild(removeBtn);
row.appendChild(actions);
listContainer.appendChild(row);
});
};
renderSteps();
// Add step buttons
const addRow = createElement('div', 'login-script-add-row');
const addWaitBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Wait', type: 'button' });
addWaitBtn.addEventListener('mousedown', e => e.preventDefault());
addWaitBtn.addEventListener('click', () => {
currentSteps.push({ type: 'wait', value: '', timeout: 10 });
renderSteps();
});
const addSendBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Send', type: 'button' });
addSendBtn.addEventListener('mousedown', e => e.preventDefault());
addSendBtn.addEventListener('click', () => {
currentSteps.push({ type: 'send', value: '', timeout: 0 });
renderSteps();
});
addRow.appendChild(addWaitBtn);
addRow.appendChild(addSendBtn);
content.appendChild(addRow);
header.addEventListener('mousedown', (e) => e.preventDefault());
header.addEventListener('click', () => {
collapsed = !collapsed;
content.style.display = collapsed ? 'none' : 'block';
header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
});
section.appendChild(header);
section.appendChild(content);
return section;
}
_renderSection(title, fields, startCollapsed = false) {
const section = createElement('div', 'form-section');
const header = createElement('div', 'form-section-header');
@@ -973,6 +1150,18 @@ class ProfileEditor {
fi++;
}
p.ssh.portForwards = pfs;
// Login scripts
const loginScriptsRaw = val('ssh.loginScripts');
if (loginScriptsRaw) {
try {
p.ssh.loginScripts = JSON.parse(loginScriptsRaw);
} catch (_) {
p.ssh.loginScripts = [];
}
} else {
p.ssh.loginScripts = [];
}
}
// Telnet-specific
@@ -985,6 +1174,18 @@ class ProfileEditor {
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
p.telnet.autoReconnect = val('telnet.autoReconnect') || false;
p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5;
// Login scripts
const telLoginScriptsRaw = val('telnet.loginScripts');
if (telLoginScriptsRaw) {
try {
p.telnet.loginScripts = JSON.parse(telLoginScriptsRaw);
} catch (_) {
p.telnet.loginScripts = [];
}
} else {
p.telnet.loginScripts = [];
}
}
// RAW-specific