From 1463394cbb30466df882ffdfc5b23d1caa999ca6 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 17 Apr 2026 00:00:00 +0100 Subject: [PATCH] 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 --- frontend/src/css/forms.css | 99 +++++++++++++++++ frontend/src/js/profiles.js | 201 +++++++++++++++++++++++++++++++++++ internal/models/models.go | 25 +++-- internal/sessions/manager.go | 142 +++++++++++++++++++++++++ 4 files changed, 459 insertions(+), 8 deletions(-) diff --git a/frontend/src/css/forms.css b/frontend/src/css/forms.css index b787e29..30bd814 100644 --- a/frontend/src/css/forms.css +++ b/frontend/src/css/forms.css @@ -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); +} diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 172aa74..7b6bbb1 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -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 = ` + ▸ + Login Script + `; + + 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 diff --git a/internal/models/models.go b/internal/models/models.go index c1d2ff1..9922fdf 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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. diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 0c1020c..ba4b4d3 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -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()