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);
|
padding: var(--space-1) var(--space-2);
|
||||||
height: 30px;
|
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: [],
|
portForwards: [],
|
||||||
startupCommand: '',
|
startupCommand: '',
|
||||||
envVars: {},
|
envVars: {},
|
||||||
|
loginScripts: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
telnet: {
|
telnet: {
|
||||||
@@ -39,6 +40,7 @@ const DEFAULTS = {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
negotiationDebug: false,
|
negotiationDebug: false,
|
||||||
lineMode: false,
|
lineMode: false,
|
||||||
|
loginScripts: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
raw: {
|
raw: {
|
||||||
@@ -390,6 +392,9 @@ class ProfileEditor {
|
|||||||
}),
|
}),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
// Login Scripts (expect-style)
|
||||||
|
sections.push(this._renderLoginScriptSection('ssh', ssh.loginScripts || []));
|
||||||
|
|
||||||
// SSH Agent
|
// SSH Agent
|
||||||
sections.push(this._renderSection('SSH Agent', [
|
sections.push(this._renderSection('SSH Agent', [
|
||||||
this._field('toggle', 'ssh.agentAuth', 'Use SSH Agent for Authentication', ssh.agentAuth, {
|
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),
|
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
|
||||||
], true));
|
], true));
|
||||||
|
|
||||||
|
// Login Scripts (expect-style)
|
||||||
|
sections.push(this._renderLoginScriptSection('telnet', tel.loginScripts || []));
|
||||||
|
|
||||||
sections.push(this._renderSection('Reconnection', [
|
sections.push(this._renderSection('Reconnection', [
|
||||||
this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, {
|
this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, {
|
||||||
help: 'Automatically reconnect with exponential backoff when the connection drops',
|
help: 'Automatically reconnect with exponential backoff when the connection drops',
|
||||||
@@ -571,6 +579,175 @@ class ProfileEditor {
|
|||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
* @private
|
* @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) {
|
_renderSection(title, fields, startCollapsed = false) {
|
||||||
const section = createElement('div', 'form-section');
|
const section = createElement('div', 'form-section');
|
||||||
const header = createElement('div', 'form-section-header');
|
const header = createElement('div', 'form-section-header');
|
||||||
@@ -973,6 +1150,18 @@ class ProfileEditor {
|
|||||||
fi++;
|
fi++;
|
||||||
}
|
}
|
||||||
p.ssh.portForwards = pfs;
|
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
|
// Telnet-specific
|
||||||
@@ -985,6 +1174,18 @@ class ProfileEditor {
|
|||||||
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
|
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
|
||||||
p.telnet.autoReconnect = val('telnet.autoReconnect') || false;
|
p.telnet.autoReconnect = val('telnet.autoReconnect') || false;
|
||||||
p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5;
|
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
|
// RAW-specific
|
||||||
|
|||||||
@@ -97,12 +97,20 @@ type SSHConfig struct {
|
|||||||
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
|
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
|
||||||
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||||
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||||
|
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
||||||
JumpHosts []JumpHost `json:"jumpHosts"`
|
JumpHosts []JumpHost `json:"jumpHosts"`
|
||||||
PortForwards []PortForward `json:"portForwards"`
|
PortForwards []PortForward `json:"portForwards"`
|
||||||
StartupCommand string `json:"startupCommand"`
|
StartupCommand string `json:"startupCommand"`
|
||||||
EnvVars map[string]string `json:"envVars"`
|
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.
|
// JumpHost describes a proxy/bastion host used for SSH multi-hop connections.
|
||||||
type JumpHost struct {
|
type JumpHost struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
@@ -135,14 +143,15 @@ type PortForward struct {
|
|||||||
|
|
||||||
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
||||||
type TelnetConfig struct {
|
type TelnetConfig struct {
|
||||||
LocalEcho bool `json:"localEcho"`
|
LocalEcho bool `json:"localEcho"`
|
||||||
LineEnding LineEnding `json:"lineEnding"`
|
LineEnding LineEnding `json:"lineEnding"`
|
||||||
TerminalType string `json:"terminalType"`
|
TerminalType string `json:"terminalType"`
|
||||||
Encoding string `json:"encoding"`
|
Encoding string `json:"encoding"`
|
||||||
NegotiationDebug bool `json:"negotiationDebug"`
|
NegotiationDebug bool `json:"negotiationDebug"`
|
||||||
LineMode bool `json:"lineMode"`
|
LineMode bool `json:"lineMode"`
|
||||||
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||||
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
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.
|
// RAWConfig holds RAW TCP-specific settings for a connection profile.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,6 +39,11 @@ type Session struct {
|
|||||||
recorder *recording.Recorder // active recording, nil if not recording
|
recorder *recording.Recorder // active recording, nil if not recording
|
||||||
reconnectCount int // number of reconnection attempts so far
|
reconnectCount int // number of reconnection attempts so far
|
||||||
cancelReconn context.CancelFunc // cancels an in-progress reconnect backoff
|
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.
|
// 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 {
|
if rec != nil {
|
||||||
rec.WriteOutput(data)
|
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.
|
// onError — report connection errors to the frontend.
|
||||||
func(err error) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +567,19 @@ func (m *Manager) connectTelnet(sess *Session) error {
|
|||||||
if rec != nil {
|
if rec != nil {
|
||||||
rec.WriteOutput(data)
|
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
|
// onError
|
||||||
func(err error) {
|
func(err error) {
|
||||||
@@ -574,6 +612,13 @@ func (m *Manager) connectTelnet(sess *Session) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sess.telnetClient = client
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,6 +1202,103 @@ func (m *Manager) closeSession(sess *Session) {
|
|||||||
m.mu.Unlock()
|
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.
|
// handleSessionClosed is called when a protocol client reports closure.
|
||||||
func (m *Manager) handleSessionClosed(sessionID string) {
|
func (m *Manager) handleSessionClosed(sessionID string) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
Reference in New Issue
Block a user