add step list for server creation
This commit is contained in:
245
local/utl/command/callback_executor.go
Normal file
245
local/utl/command/callback_executor.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CallbackInteractiveCommandExecutor struct {
|
||||
*InteractiveCommandExecutor
|
||||
callbacks *CallbackConfig
|
||||
serverID uuid.UUID
|
||||
}
|
||||
|
||||
func NewCallbackInteractiveCommandExecutor(baseExecutor *CommandExecutor, tfaManager *model.Steam2FAManager, callbacks *CallbackConfig, serverID uuid.UUID) *CallbackInteractiveCommandExecutor {
|
||||
if callbacks == nil {
|
||||
callbacks = DefaultCallbackConfig()
|
||||
}
|
||||
|
||||
return &CallbackInteractiveCommandExecutor{
|
||||
InteractiveCommandExecutor: &InteractiveCommandExecutor{
|
||||
CommandExecutor: baseExecutor,
|
||||
tfaManager: tfaManager,
|
||||
},
|
||||
callbacks: callbacks,
|
||||
serverID: serverID,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CallbackInteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, serverID *uuid.UUID, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, e.ExePath, args...)
|
||||
|
||||
if e.WorkDir != "" {
|
||||
cmd.Dir = e.WorkDir
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdin pipe: %v", err)
|
||||
}
|
||||
defer stdin.Close()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdout pipe: %v", err)
|
||||
}
|
||||
defer stdout.Close()
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stderr pipe: %v", err)
|
||||
}
|
||||
defer stderr.Close()
|
||||
|
||||
logging.Info("Executing interactive command with callbacks: %s %s", e.ExePath, strings.Join(args, " "))
|
||||
|
||||
e.callbacks.OnCommand(e.serverID, e.ExePath, args, false, false, "")
|
||||
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Starting command: %s %s", e.ExePath, strings.Join(args, " ")), false)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to start command: %v", err), true)
|
||||
return fmt.Errorf("failed to start command: %v", err)
|
||||
}
|
||||
|
||||
outputDone := make(chan error, 1)
|
||||
cmdDone := make(chan error, 1)
|
||||
|
||||
go e.monitorOutputWithCallbacks(ctx, stdout, stderr, serverID, outputDone)
|
||||
|
||||
go func() {
|
||||
cmdDone <- cmd.Wait()
|
||||
}()
|
||||
|
||||
var cmdErr, outputErr error
|
||||
completedCount := 0
|
||||
|
||||
for completedCount < 2 {
|
||||
select {
|
||||
case cmdErr = <-cmdDone:
|
||||
completedCount++
|
||||
logging.Info("Command execution completed")
|
||||
e.callbacks.OnOutput(e.serverID, "Command execution completed", false)
|
||||
case outputErr = <-outputDone:
|
||||
completedCount++
|
||||
logging.Info("Output monitoring completed")
|
||||
case <-ctx.Done():
|
||||
e.callbacks.OnOutput(e.serverID, "Command execution cancelled", true)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if outputErr != nil {
|
||||
logging.Warn("Output monitoring error: %v", outputErr)
|
||||
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Output monitoring error: %v", outputErr), true)
|
||||
}
|
||||
|
||||
success := cmdErr == nil
|
||||
errorMsg := ""
|
||||
if cmdErr != nil {
|
||||
errorMsg = cmdErr.Error()
|
||||
}
|
||||
e.callbacks.OnCommand(e.serverID, e.ExePath, args, true, success, errorMsg)
|
||||
|
||||
return cmdErr
|
||||
}
|
||||
|
||||
func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx context.Context, stdout, stderr io.Reader, serverID *uuid.UUID, done chan error) {
|
||||
defer func() {
|
||||
select {
|
||||
case done <- nil:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
stdoutScanner := bufio.NewScanner(stdout)
|
||||
stderrScanner := bufio.NewScanner(stderr)
|
||||
|
||||
outputChan := make(chan outputLine, 100)
|
||||
readersDone := make(chan struct{}, 2)
|
||||
|
||||
steamConsoleStarted := false
|
||||
tfaRequestCreated := false
|
||||
|
||||
go func() {
|
||||
defer func() { readersDone <- struct{}{} }()
|
||||
for stdoutScanner.Scan() {
|
||||
line := stdoutScanner.Text()
|
||||
if e.LogOutput {
|
||||
logging.Info("STDOUT: %s", line)
|
||||
}
|
||||
e.callbacks.OnOutput(e.serverID, line, false)
|
||||
|
||||
select {
|
||||
case outputChan <- outputLine{text: line, isError: false}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := stdoutScanner.Err(); err != nil {
|
||||
logging.Warn("Stdout scanner error: %v", err)
|
||||
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Stdout scanner error: %v", err), true)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer func() { readersDone <- struct{}{} }()
|
||||
for stderrScanner.Scan() {
|
||||
line := stderrScanner.Text()
|
||||
if e.LogOutput {
|
||||
logging.Info("STDERR: %s", line)
|
||||
}
|
||||
e.callbacks.OnOutput(e.serverID, line, true)
|
||||
|
||||
select {
|
||||
case outputChan <- outputLine{text: line, isError: true}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := stderrScanner.Err(); err != nil {
|
||||
logging.Warn("Stderr scanner error: %v", err)
|
||||
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Stderr scanner error: %v", err), true)
|
||||
}
|
||||
}()
|
||||
|
||||
readersFinished := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
done <- ctx.Err()
|
||||
return
|
||||
case <-readersDone:
|
||||
readersFinished++
|
||||
if readersFinished == 2 {
|
||||
close(outputChan)
|
||||
for lineData := range outputChan {
|
||||
if e.is2FAPrompt(lineData.text) {
|
||||
if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil {
|
||||
logging.Error("Failed to handle 2FA prompt: %v", err)
|
||||
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case lineData, ok := <-outputChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
lowerLine := strings.ToLower(lineData.text)
|
||||
if strings.Contains(lowerLine, "steam console client") && strings.Contains(lowerLine, "valve corporation") {
|
||||
steamConsoleStarted = true
|
||||
logging.Info("Steam Console Client startup detected - will monitor for 2FA hang")
|
||||
e.callbacks.OnOutput(e.serverID, "Steam Console Client startup detected", false)
|
||||
}
|
||||
|
||||
if e.is2FAPrompt(lineData.text) {
|
||||
if !tfaRequestCreated {
|
||||
e.callbacks.OnOutput(e.serverID, "2FA prompt detected - waiting for user confirmation", false)
|
||||
if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil {
|
||||
logging.Error("Failed to handle 2FA prompt: %v", err)
|
||||
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
tfaRequestCreated = true
|
||||
}
|
||||
}
|
||||
|
||||
if tfaRequestCreated && e.isSteamContinuing(lineData.text) {
|
||||
logging.Info("Steam CMD appears to have continued after 2FA confirmation")
|
||||
e.callbacks.OnOutput(e.serverID, "Steam CMD continued after 2FA confirmation", false)
|
||||
e.autoCompletePendingRequests(serverID)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
if steamConsoleStarted && !tfaRequestCreated {
|
||||
logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA")
|
||||
e.callbacks.OnOutput(e.serverID, "Waiting for Steam Guard 2FA confirmation...", false)
|
||||
if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil {
|
||||
logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err)
|
||||
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true)
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
tfaRequestCreated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type outputLine struct {
|
||||
text string
|
||||
isError bool
|
||||
}
|
||||
19
local/utl/command/callbacks.go
Normal file
19
local/utl/command/callbacks.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package command
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type OutputCallback func(serverID uuid.UUID, output string, isError bool)
|
||||
|
||||
type CommandCallback func(serverID uuid.UUID, command string, args []string, completed bool, success bool, error string)
|
||||
|
||||
type CallbackConfig struct {
|
||||
OnOutput OutputCallback
|
||||
OnCommand CommandCallback
|
||||
}
|
||||
|
||||
func DefaultCallbackConfig() *CallbackConfig {
|
||||
return &CallbackConfig{
|
||||
OnOutput: func(uuid.UUID, string, bool) {},
|
||||
OnCommand: func(uuid.UUID, string, []string, bool, bool, string) {},
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CommandExecutor provides a base structure for executing commands
|
||||
type CommandExecutor struct {
|
||||
// Base executable path
|
||||
ExePath string
|
||||
// Working directory for commands
|
||||
WorkDir string
|
||||
// Whether to capture and log output
|
||||
ExePath string
|
||||
WorkDir string
|
||||
LogOutput bool
|
||||
}
|
||||
|
||||
// CommandBuilder helps build command arguments
|
||||
type CommandBuilder struct {
|
||||
args []string
|
||||
}
|
||||
@@ -48,10 +43,9 @@ func (b *CommandBuilder) Build() []string {
|
||||
return b.args
|
||||
}
|
||||
|
||||
// Execute runs a command with the given arguments
|
||||
func (e *CommandExecutor) Execute(args ...string) error {
|
||||
cmd := exec.Command(e.ExePath, args...)
|
||||
|
||||
|
||||
if e.WorkDir != "" {
|
||||
cmd.Dir = e.WorkDir
|
||||
}
|
||||
@@ -65,15 +59,13 @@ func (e *CommandExecutor) Execute(args ...string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ExecuteWithBuilder runs a command using a CommandBuilder
|
||||
func (e *CommandExecutor) ExecuteWithBuilder(builder *CommandBuilder) error {
|
||||
return e.Execute(builder.Build()...)
|
||||
}
|
||||
|
||||
// ExecuteWithOutput runs a command and returns its output
|
||||
func (e *CommandExecutor) ExecuteWithOutput(args ...string) (string, error) {
|
||||
cmd := exec.Command(e.ExePath, args...)
|
||||
|
||||
|
||||
if e.WorkDir != "" {
|
||||
cmd.Dir = e.WorkDir
|
||||
}
|
||||
@@ -83,10 +75,9 @@ func (e *CommandExecutor) ExecuteWithOutput(args ...string) (string, error) {
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
// ExecuteWithEnv runs a command with custom environment variables
|
||||
func (e *CommandExecutor) ExecuteWithEnv(env []string, args ...string) error {
|
||||
cmd := exec.Command(e.ExePath, args...)
|
||||
|
||||
|
||||
if e.WorkDir != "" {
|
||||
cmd.Dir = e.WorkDir
|
||||
}
|
||||
@@ -100,4 +91,4 @@ func (e *CommandExecutor) ExecuteWithEnv(env []string, args ...string) error {
|
||||
|
||||
logging.Info("Executing command: %s %s", e.ExePath, strings.Join(args, " "))
|
||||
return cmd.Run()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// InteractiveCommandExecutor extends CommandExecutor to handle interactive commands
|
||||
type InteractiveCommandExecutor struct {
|
||||
*CommandExecutor
|
||||
tfaManager *model.Steam2FAManager
|
||||
@@ -29,7 +27,6 @@ func NewInteractiveCommandExecutor(baseExecutor *CommandExecutor, tfaManager *mo
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteInteractive runs a command that may require 2FA input
|
||||
func (e *InteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, serverID *uuid.UUID, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, e.ExePath, args...)
|
||||
|
||||
@@ -37,7 +34,6 @@ func (e *InteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, ser
|
||||
cmd.Dir = e.WorkDir
|
||||
}
|
||||
|
||||
// Create pipes for stdin, stdout, and stderr
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdin pipe: %v", err)
|
||||
@@ -58,7 +54,6 @@ func (e *InteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, ser
|
||||
|
||||
logging.Info("Executing interactive command: %s %s", e.ExePath, strings.Join(args, " "))
|
||||
|
||||
// Enable debug mode if environment variable is set
|
||||
debugMode := os.Getenv("STEAMCMD_DEBUG") == "true"
|
||||
if debugMode {
|
||||
logging.Info("STEAMCMD_DEBUG mode enabled - will log all output and create proactive 2FA requests")
|
||||
@@ -68,19 +63,15 @@ func (e *InteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, ser
|
||||
return fmt.Errorf("failed to start command: %v", err)
|
||||
}
|
||||
|
||||
// Create channels for output monitoring
|
||||
outputDone := make(chan error, 1)
|
||||
cmdDone := make(chan error, 1)
|
||||
|
||||
// Monitor stdout and stderr for 2FA prompts
|
||||
go e.monitorOutput(ctx, stdout, stderr, serverID, outputDone)
|
||||
|
||||
// Wait for the command to finish in a separate goroutine
|
||||
go func() {
|
||||
cmdDone <- cmd.Wait()
|
||||
}()
|
||||
|
||||
// Wait for both command and output monitoring to complete
|
||||
var cmdErr, outputErr error
|
||||
completedCount := 0
|
||||
|
||||
@@ -112,18 +103,15 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
}
|
||||
}()
|
||||
|
||||
// Create scanners for both outputs
|
||||
stdoutScanner := bufio.NewScanner(stdout)
|
||||
stderrScanner := bufio.NewScanner(stderr)
|
||||
|
||||
outputChan := make(chan string, 100) // Buffered channel to prevent blocking
|
||||
outputChan := make(chan string, 100)
|
||||
readersDone := make(chan struct{}, 2)
|
||||
|
||||
// Track Steam Console startup for this specific execution
|
||||
steamConsoleStarted := false
|
||||
tfaRequestCreated := false
|
||||
|
||||
// Read from stdout
|
||||
go func() {
|
||||
defer func() { readersDone <- struct{}{} }()
|
||||
for stdoutScanner.Scan() {
|
||||
@@ -131,7 +119,6 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
if e.LogOutput {
|
||||
logging.Info("STDOUT: %s", line)
|
||||
}
|
||||
// Always log Steam CMD output for debugging 2FA issues
|
||||
if strings.Contains(strings.ToLower(line), "steam") {
|
||||
logging.Info("STEAM_DEBUG: %s", line)
|
||||
}
|
||||
@@ -146,7 +133,6 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
}
|
||||
}()
|
||||
|
||||
// Read from stderr
|
||||
go func() {
|
||||
defer func() { readersDone <- struct{}{} }()
|
||||
for stderrScanner.Scan() {
|
||||
@@ -154,7 +140,6 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
if e.LogOutput {
|
||||
logging.Info("STDERR: %s", line)
|
||||
}
|
||||
// Always log Steam CMD errors for debugging 2FA issues
|
||||
if strings.Contains(strings.ToLower(line), "steam") {
|
||||
logging.Info("STEAM_DEBUG_ERR: %s", line)
|
||||
}
|
||||
@@ -169,7 +154,6 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
}
|
||||
}()
|
||||
|
||||
// Monitor for completion and 2FA prompts
|
||||
readersFinished := 0
|
||||
for {
|
||||
select {
|
||||
@@ -179,9 +163,7 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
case <-readersDone:
|
||||
readersFinished++
|
||||
if readersFinished == 2 {
|
||||
// Both readers are done, close output channel and finish monitoring
|
||||
close(outputChan)
|
||||
// Drain any remaining output
|
||||
for line := range outputChan {
|
||||
if e.is2FAPrompt(line) {
|
||||
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil {
|
||||
@@ -195,18 +177,15 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
}
|
||||
case line, ok := <-outputChan:
|
||||
if !ok {
|
||||
// Channel closed, we're done
|
||||
return
|
||||
}
|
||||
|
||||
// Check for Steam Console startup
|
||||
lowerLine := strings.ToLower(line)
|
||||
if strings.Contains(lowerLine, "steam console client") && strings.Contains(lowerLine, "valve corporation") {
|
||||
steamConsoleStarted = true
|
||||
logging.Info("Steam Console Client startup detected - will monitor for 2FA hang")
|
||||
}
|
||||
|
||||
// Check if this line indicates a 2FA prompt
|
||||
if e.is2FAPrompt(line) {
|
||||
if !tfaRequestCreated {
|
||||
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil {
|
||||
@@ -218,15 +197,11 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Steam CMD continued after 2FA (auto-completion)
|
||||
if tfaRequestCreated && e.isSteamContinuing(line) {
|
||||
logging.Info("Steam CMD appears to have continued after 2FA confirmation - auto-completing 2FA request")
|
||||
// Auto-complete any pending 2FA requests for this server
|
||||
e.autoCompletePendingRequests(serverID)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
// If Steam Console has started and we haven't seen output for 15 seconds,
|
||||
// it's very likely waiting for 2FA confirmation
|
||||
if steamConsoleStarted && !tfaRequestCreated {
|
||||
logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA")
|
||||
if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil {
|
||||
@@ -243,7 +218,6 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
|
||||
}
|
||||
|
||||
func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
|
||||
// Common SteamCMD 2FA prompts - updated with more comprehensive patterns
|
||||
twoFAKeywords := []string{
|
||||
"please enter your steam guard code",
|
||||
"steam guard",
|
||||
@@ -272,7 +246,6 @@ func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for patterns that might indicate Steam is waiting for input
|
||||
waitingPatterns := []string{
|
||||
"waiting for",
|
||||
"please enter",
|
||||
@@ -330,12 +303,9 @@ func (e *InteractiveCommandExecutor) autoCompletePendingRequests(serverID *uuid.
|
||||
func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLine string, serverID *uuid.UUID) error {
|
||||
logging.Info("2FA prompt detected: %s", promptLine)
|
||||
|
||||
// Create a 2FA request
|
||||
request := e.tfaManager.CreateRequest(promptLine, serverID)
|
||||
logging.Info("Created 2FA request with ID: %s", request.ID)
|
||||
|
||||
// Wait for user to complete the 2FA process
|
||||
// Use a reasonable timeout (e.g., 5 minutes)
|
||||
timeout := 5 * time.Minute
|
||||
success, err := e.tfaManager.WaitForCompletion(request.ID, timeout)
|
||||
|
||||
@@ -352,271 +322,3 @@ func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLi
|
||||
logging.Info("2FA completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebSocketInteractiveCommandExecutor extends InteractiveCommandExecutor to stream output via WebSocket
|
||||
type WebSocketInteractiveCommandExecutor struct {
|
||||
*InteractiveCommandExecutor
|
||||
wsService interface{} // Using interface{} to avoid circular import
|
||||
serverID uuid.UUID
|
||||
}
|
||||
|
||||
// NewInteractiveCommandExecutorWithWebSocket creates a new WebSocket-enabled interactive command executor
|
||||
func NewInteractiveCommandExecutorWithWebSocket(baseExecutor *CommandExecutor, tfaManager *model.Steam2FAManager, wsService interface{}, serverID uuid.UUID) *WebSocketInteractiveCommandExecutor {
|
||||
return &WebSocketInteractiveCommandExecutor{
|
||||
InteractiveCommandExecutor: &InteractiveCommandExecutor{
|
||||
CommandExecutor: baseExecutor,
|
||||
tfaManager: tfaManager,
|
||||
},
|
||||
wsService: wsService,
|
||||
serverID: serverID,
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteInteractive runs a command with WebSocket output streaming
|
||||
func (e *WebSocketInteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, serverID *uuid.UUID, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, e.ExePath, args...)
|
||||
|
||||
if e.WorkDir != "" {
|
||||
cmd.Dir = e.WorkDir
|
||||
}
|
||||
|
||||
// Create pipes for stdin, stdout, and stderr
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdin pipe: %v", err)
|
||||
}
|
||||
defer stdin.Close()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdout pipe: %v", err)
|
||||
}
|
||||
defer stdout.Close()
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stderr pipe: %v", err)
|
||||
}
|
||||
defer stderr.Close()
|
||||
|
||||
logging.Info("Executing interactive command with WebSocket streaming: %s %s", e.ExePath, strings.Join(args, " "))
|
||||
|
||||
// Broadcast command start via WebSocket
|
||||
e.broadcastSteamOutput(fmt.Sprintf("Starting command: %s %s", e.ExePath, strings.Join(args, " ")), false)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
e.broadcastSteamOutput(fmt.Sprintf("Failed to start command: %v", err), true)
|
||||
return fmt.Errorf("failed to start command: %v", err)
|
||||
}
|
||||
|
||||
// Create channels for output monitoring
|
||||
outputDone := make(chan error, 1)
|
||||
cmdDone := make(chan error, 1)
|
||||
|
||||
// Monitor stdout and stderr for 2FA prompts with WebSocket streaming
|
||||
go e.monitorOutputWithWebSocket(ctx, stdout, stderr, serverID, outputDone)
|
||||
|
||||
// Wait for the command to finish in a separate goroutine
|
||||
go func() {
|
||||
cmdDone <- cmd.Wait()
|
||||
}()
|
||||
|
||||
// Wait for both command and output monitoring to complete
|
||||
var cmdErr, outputErr error
|
||||
completedCount := 0
|
||||
|
||||
for completedCount < 2 {
|
||||
select {
|
||||
case cmdErr = <-cmdDone:
|
||||
completedCount++
|
||||
logging.Info("Command execution completed")
|
||||
e.broadcastSteamOutput("Command execution completed", false)
|
||||
case outputErr = <-outputDone:
|
||||
completedCount++
|
||||
logging.Info("Output monitoring completed")
|
||||
case <-ctx.Done():
|
||||
e.broadcastSteamOutput("Command execution cancelled", true)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if outputErr != nil {
|
||||
logging.Warn("Output monitoring error: %v", outputErr)
|
||||
e.broadcastSteamOutput(fmt.Sprintf("Output monitoring error: %v", outputErr), true)
|
||||
}
|
||||
|
||||
return cmdErr
|
||||
}
|
||||
|
||||
// broadcastSteamOutput sends output to WebSocket using reflection to avoid circular imports
|
||||
func (e *WebSocketInteractiveCommandExecutor) broadcastSteamOutput(output string, isError bool) {
|
||||
if e.wsService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Use reflection to call BroadcastSteamOutput method
|
||||
wsServiceVal := reflect.ValueOf(e.wsService)
|
||||
method := wsServiceVal.MethodByName("BroadcastSteamOutput")
|
||||
if !method.IsValid() {
|
||||
logging.Warn("BroadcastSteamOutput method not found on WebSocket service")
|
||||
return
|
||||
}
|
||||
|
||||
// Call the method with parameters: serverID, output, isError
|
||||
args := []reflect.Value{
|
||||
reflect.ValueOf(e.serverID),
|
||||
reflect.ValueOf(output),
|
||||
reflect.ValueOf(isError),
|
||||
}
|
||||
method.Call(args)
|
||||
}
|
||||
|
||||
// monitorOutputWithWebSocket monitors command output and streams it via WebSocket
|
||||
func (e *WebSocketInteractiveCommandExecutor) monitorOutputWithWebSocket(ctx context.Context, stdout, stderr io.Reader, serverID *uuid.UUID, done chan error) {
|
||||
defer func() {
|
||||
select {
|
||||
case done <- nil:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
// Create scanners for both outputs
|
||||
stdoutScanner := bufio.NewScanner(stdout)
|
||||
stderrScanner := bufio.NewScanner(stderr)
|
||||
|
||||
outputChan := make(chan outputLine, 100) // Buffered channel to prevent blocking
|
||||
readersDone := make(chan struct{}, 2)
|
||||
|
||||
// Track Steam Console startup for this specific execution
|
||||
steamConsoleStarted := false
|
||||
tfaRequestCreated := false
|
||||
|
||||
// Read from stdout
|
||||
go func() {
|
||||
defer func() { readersDone <- struct{}{} }()
|
||||
for stdoutScanner.Scan() {
|
||||
line := stdoutScanner.Text()
|
||||
if e.LogOutput {
|
||||
logging.Info("STDOUT: %s", line)
|
||||
}
|
||||
// Stream output via WebSocket
|
||||
e.broadcastSteamOutput(line, false)
|
||||
|
||||
select {
|
||||
case outputChan <- outputLine{text: line, isError: false}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := stdoutScanner.Err(); err != nil {
|
||||
logging.Warn("Stdout scanner error: %v", err)
|
||||
e.broadcastSteamOutput(fmt.Sprintf("Stdout scanner error: %v", err), true)
|
||||
}
|
||||
}()
|
||||
|
||||
// Read from stderr
|
||||
go func() {
|
||||
defer func() { readersDone <- struct{}{} }()
|
||||
for stderrScanner.Scan() {
|
||||
line := stderrScanner.Text()
|
||||
if e.LogOutput {
|
||||
logging.Info("STDERR: %s", line)
|
||||
}
|
||||
// Stream error output via WebSocket
|
||||
e.broadcastSteamOutput(line, true)
|
||||
|
||||
select {
|
||||
case outputChan <- outputLine{text: line, isError: true}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := stderrScanner.Err(); err != nil {
|
||||
logging.Warn("Stderr scanner error: %v", err)
|
||||
e.broadcastSteamOutput(fmt.Sprintf("Stderr scanner error: %v", err), true)
|
||||
}
|
||||
}()
|
||||
|
||||
// Monitor for completion and 2FA prompts
|
||||
readersFinished := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
done <- ctx.Err()
|
||||
return
|
||||
case <-readersDone:
|
||||
readersFinished++
|
||||
if readersFinished == 2 {
|
||||
// Both readers are done, close output channel and finish monitoring
|
||||
close(outputChan)
|
||||
// Drain any remaining output
|
||||
for lineData := range outputChan {
|
||||
if e.is2FAPrompt(lineData.text) {
|
||||
if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil {
|
||||
logging.Error("Failed to handle 2FA prompt: %v", err)
|
||||
e.broadcastSteamOutput(fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case lineData, ok := <-outputChan:
|
||||
if !ok {
|
||||
// Channel closed, we're done
|
||||
return
|
||||
}
|
||||
|
||||
// Check for Steam Console startup
|
||||
lowerLine := strings.ToLower(lineData.text)
|
||||
if strings.Contains(lowerLine, "steam console client") && strings.Contains(lowerLine, "valve corporation") {
|
||||
steamConsoleStarted = true
|
||||
logging.Info("Steam Console Client startup detected - will monitor for 2FA hang")
|
||||
e.broadcastSteamOutput("Steam Console Client startup detected", false)
|
||||
}
|
||||
|
||||
// Check if this line indicates a 2FA prompt
|
||||
if e.is2FAPrompt(lineData.text) {
|
||||
if !tfaRequestCreated {
|
||||
e.broadcastSteamOutput("2FA prompt detected - waiting for user confirmation", false)
|
||||
if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil {
|
||||
logging.Error("Failed to handle 2FA prompt: %v", err)
|
||||
e.broadcastSteamOutput(fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
tfaRequestCreated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Steam CMD continued after 2FA (auto-completion)
|
||||
if tfaRequestCreated && e.isSteamContinuing(lineData.text) {
|
||||
logging.Info("Steam CMD appears to have continued after 2FA confirmation")
|
||||
e.broadcastSteamOutput("Steam CMD continued after 2FA confirmation", false)
|
||||
// Auto-complete any pending 2FA requests for this server
|
||||
e.autoCompletePendingRequests(serverID)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
// If Steam Console has started and we haven't seen output for 15 seconds,
|
||||
// it's very likely waiting for 2FA confirmation
|
||||
if steamConsoleStarted && !tfaRequestCreated {
|
||||
logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA")
|
||||
e.broadcastSteamOutput("Waiting for Steam Guard 2FA confirmation...", false)
|
||||
if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil {
|
||||
logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err)
|
||||
e.broadcastSteamOutput(fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true)
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
tfaRequestCreated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// outputLine represents a line of output with error status
|
||||
type outputLine struct {
|
||||
text string
|
||||
isError bool
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user