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