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
+17 -8
View File
@@ -97,12 +97,20 @@ type SSHConfig struct {
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
JumpHosts []JumpHost `json:"jumpHosts"`
PortForwards []PortForward `json:"portForwards"`
StartupCommand string `json:"startupCommand"`
EnvVars map[string]string `json:"envVars"`
}
// LoginScriptStep represents a single step in an expect-style auto-login sequence.
type LoginScriptStep struct {
Type string `json:"type"` // "wait" or "send"
Value string `json:"value"` // text to wait for / text to send
Timeout int `json:"timeout"` // max seconds to wait (for "wait" steps; 0 = use default 10s)
}
// JumpHost describes a proxy/bastion host used for SSH multi-hop connections.
type JumpHost struct {
Host string `json:"host"`
@@ -135,14 +143,15 @@ type PortForward struct {
// TelnetConfig holds Telnet-specific settings for a connection profile.
type TelnetConfig struct {
LocalEcho bool `json:"localEcho"`
LineEnding LineEnding `json:"lineEnding"`
TerminalType string `json:"terminalType"`
Encoding string `json:"encoding"`
NegotiationDebug bool `json:"negotiationDebug"`
LineMode bool `json:"lineMode"`
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LocalEcho bool `json:"localEcho"`
LineEnding LineEnding `json:"lineEnding"`
TerminalType string `json:"terminalType"`
Encoding string `json:"encoding"`
NegotiationDebug bool `json:"negotiationDebug"`
LineMode bool `json:"lineMode"`
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
}
// RAWConfig holds RAW TCP-specific settings for a connection profile.
+142
View File
@@ -7,6 +7,7 @@ import (
"context"
"encoding/base64"
"fmt"
"strings"
"sync"
"time"
@@ -38,6 +39,11 @@ type Session struct {
recorder *recording.Recorder // active recording, nil if not recording
reconnectCount int // number of reconnection attempts so far
cancelReconn context.CancelFunc // cancels an in-progress reconnect backoff
// Login script support — temporary buffer for expect-style matching.
loginBuf []byte
loginBufMu sync.Mutex
loginDone chan struct{} // closed when login script completes or is abandoned
}
// Manager handles the lifecycle of all active sessions.
@@ -463,6 +469,20 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
if rec != nil {
rec.WriteOutput(data)
}
// Feed data to the login script buffer if active.
sess.loginBufMu.Lock()
if sess.loginDone != nil {
select {
case <-sess.loginDone:
// Login script finished — stop buffering.
default:
sess.loginBuf = append(sess.loginBuf, data...)
if len(sess.loginBuf) > 16384 {
sess.loginBuf = sess.loginBuf[len(sess.loginBuf)-16384:]
}
}
}
sess.loginBufMu.Unlock()
},
// onError — report connection errors to the frontend.
func(err error) {
@@ -520,6 +540,11 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
}
}
// Execute login scripts if configured.
if sshConf != nil && len(sshConf.LoginScripts) > 0 {
go m.executeLoginScripts(sess, sshConf.LoginScripts)
}
return nil
}
@@ -542,6 +567,19 @@ func (m *Manager) connectTelnet(sess *Session) error {
if rec != nil {
rec.WriteOutput(data)
}
// Feed data to the login script buffer if active.
sess.loginBufMu.Lock()
if sess.loginDone != nil {
select {
case <-sess.loginDone:
default:
sess.loginBuf = append(sess.loginBuf, data...)
if len(sess.loginBuf) > 16384 {
sess.loginBuf = sess.loginBuf[len(sess.loginBuf)-16384:]
}
}
}
sess.loginBufMu.Unlock()
},
// onError
func(err error) {
@@ -574,6 +612,13 @@ func (m *Manager) connectTelnet(sess *Session) error {
}
sess.telnetClient = client
// Execute login scripts if configured.
telConf := profile.Telnet
if telConf != nil && len(telConf.LoginScripts) > 0 {
go m.executeLoginScripts(sess, telConf.LoginScripts)
}
return nil
}
@@ -1157,6 +1202,103 @@ func (m *Manager) closeSession(sess *Session) {
m.mu.Unlock()
}
// ---------------------------------------------------------------------------
// Login script execution (expect-style send/wait)
// ---------------------------------------------------------------------------
// executeLoginScripts processes an expect-style login script sequence against
// a connected session. "wait" steps block until the specified text appears in
// the session output (with a timeout). "send" steps write text to the session
// input. The session's loginBuf/loginDone fields are used for pattern matching.
func (m *Manager) executeLoginScripts(sess *Session, steps []models.LoginScriptStep) {
// Initialise the login buffer so the onData hook starts accumulating.
sess.loginBufMu.Lock()
sess.loginBuf = nil
sess.loginDone = make(chan struct{})
sess.loginBufMu.Unlock()
defer func() {
sess.loginBufMu.Lock()
close(sess.loginDone)
sess.loginBuf = nil
sess.loginBufMu.Unlock()
}()
// Short delay to allow the shell to start and produce its initial prompt.
time.Sleep(500 * time.Millisecond)
for i, step := range steps {
// Check session is still alive.
m.mu.RLock()
state := sess.Info.State
m.mu.RUnlock()
if state != models.SessionConnected {
return
}
switch step.Type {
case "wait":
timeout := time.Duration(step.Timeout) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
if err := m.waitForOutput(sess, step.Value, timeout); err != nil {
m.emitEvent("session:debug", map[string]interface{}{
"sessionID": sess.Info.ID,
"message": fmt.Sprintf("login script step %d timed out waiting for %q", i+1, step.Value),
})
return
}
case "send":
data := []byte(step.Value + "\n")
if err := m.WriteToSession(sess.Info.ID, data); err != nil {
m.emitEvent("session:debug", map[string]interface{}{
"sessionID": sess.Info.ID,
"message": fmt.Sprintf("login script step %d failed to send: %v", i+1, err),
})
return
}
// Small inter-step delay for the remote to process.
time.Sleep(200 * time.Millisecond)
default:
// Unknown step type — skip.
}
}
}
// waitForOutput polls the session's login buffer until the specified text
// appears or the timeout expires.
func (m *Manager) waitForOutput(sess *Session, pattern string, timeout time.Duration) error {
if pattern == "" {
return nil
}
deadline := time.After(timeout)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-deadline:
return fmt.Errorf("timeout waiting for %q", pattern)
case <-ticker.C:
sess.loginBufMu.Lock()
found := strings.Contains(string(sess.loginBuf), pattern)
if found {
// Clear the buffer up to the match so subsequent waits start fresh.
idx := strings.Index(string(sess.loginBuf), pattern)
sess.loginBuf = sess.loginBuf[idx+len(pattern):]
}
sess.loginBufMu.Unlock()
if found {
return nil
}
}
}
}
// handleSessionClosed is called when a protocol client reports closure.
func (m *Manager) handleSessionClosed(sessionID string) {
m.mu.Lock()