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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user