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
+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()