mirror of
https://github.com/postmannen/ctrl.git
synced 2025-04-15 00:36:31 +00:00
NB: Breaking change, since the message format have changed. Uses nats to communicate with ctrl, and nkeys for auth. Supports the use of files as templates for scripts to run. The main source for the templates are the ./files directory on the node named central. webUI: Settings are stored locally using Javascript localStorage. shortcut ctrl+t to open the template menu for webui
397 lines
11 KiB
Go
397 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
tcell "github.com/gdamore/tcell/v2"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/rivo/tview"
|
|
)
|
|
|
|
type CommandData struct {
|
|
ToNodes []string `json:"toNodes"`
|
|
ToNode string `json:"toNode"`
|
|
JetstreamToNode string `json:"jetstreamToNode,omitempty"`
|
|
UseDetectedShell bool `json:"useDetectedShell"`
|
|
Method string `json:"method"`
|
|
MethodArgs []string `json:"methodArgs"`
|
|
MethodTimeout int `json:"methodTimeout"`
|
|
FromNode string `json:"fromNode"`
|
|
ReplyMethod string `json:"replyMethod"`
|
|
ACKTimeout int `json:"ACKTimeout"`
|
|
}
|
|
|
|
// Create a global variable for the output view so it can be accessed from NATS handlers
|
|
var outputView *tview.TextView
|
|
var historyList *tview.List
|
|
var filesList *tview.List
|
|
var app *tview.Application
|
|
var nc *nats.Conn
|
|
|
|
// Store form values globally
|
|
var (
|
|
toNodes string = "btdev1"
|
|
methodArgs = []string{"/bin/bash", "-c", "ls -l"}
|
|
// Store history commands
|
|
historyCommands []string
|
|
)
|
|
|
|
func createCommandPage() tview.Primitive {
|
|
// Create a flex container for the entire page
|
|
mainFlex := tview.NewFlex().SetDirection(tview.FlexRow)
|
|
|
|
// Create form for inputs
|
|
formFields := tview.NewForm()
|
|
formFields.SetBorder(true).SetTitle("Command Form")
|
|
|
|
// Create a List for command history
|
|
historyList = tview.NewList()
|
|
historyList.SetBorder(true)
|
|
historyList.SetTitle("History")
|
|
// Handle selection from history
|
|
historyList.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
|
// Update Method Arg 3 field with selected command
|
|
methodArg3Field := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField)
|
|
methodArg3Field.SetText(mainText)
|
|
methodArgs[2] = mainText // Update global variable
|
|
app.SetFocus(formFields) // Return focus to form
|
|
})
|
|
|
|
// Create a List for files
|
|
filesList = tview.NewList()
|
|
filesList.SetBorder(true)
|
|
filesList.SetTitle("Files")
|
|
filesList.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
|
// When a file is selected, update Method Arg 3 with the CTRL_FILE template
|
|
methodArg3Field := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField)
|
|
methodArg3Field.SetText("{{CTRL_FILE:" + secondaryText + "}}")
|
|
methodArgs[2] = methodArg3Field.GetText()
|
|
app.SetFocus(formFields)
|
|
})
|
|
|
|
// Create a vertical flex for history and files lists
|
|
rightPanelFlex := tview.NewFlex().
|
|
SetDirection(tview.FlexRow).
|
|
AddItem(historyList, 0, 1, false).
|
|
AddItem(filesList, 0, 1, false)
|
|
|
|
// Create a horizontal flex for the form and right panel
|
|
topFlex := tview.NewFlex().
|
|
AddItem(formFields, 0, 2, true).
|
|
AddItem(rightPanelFlex, 0, 1, false)
|
|
|
|
// Create output text view
|
|
outputView = tview.NewTextView().
|
|
SetDynamicColors(true).
|
|
SetChangedFunc(func() {
|
|
app.Draw()
|
|
})
|
|
outputView.SetBorder(true).SetTitle("Output")
|
|
|
|
// Add form fields
|
|
formFields.AddInputField("To Nodes", toNodes, 30, nil, func(text string) {
|
|
})
|
|
|
|
formFields.AddInputField("Jetstream To Node", "", 30, nil, func(text string) {
|
|
// Value will be read directly from form when needed
|
|
})
|
|
|
|
formFields.AddCheckbox("Use Detected Shell", false, func(checked bool) {
|
|
// Value will be read directly from form when needed
|
|
})
|
|
|
|
formFields.AddInputField("Method", "cliCommand", 30, nil, func(text string) {
|
|
// Value will be read directly from form when needed
|
|
})
|
|
|
|
formFields.AddInputField("Method Arg 1", methodArgs[0], 30, nil, func(text string) {
|
|
methodArgs[0] = text
|
|
})
|
|
|
|
formFields.AddInputField("Method Arg 2", methodArgs[1], 30, nil, func(text string) {
|
|
methodArgs[1] = text
|
|
})
|
|
|
|
formFields.AddInputField("Method Arg 3", methodArgs[2], 30, nil, func(text string) {
|
|
methodArgs[2] = text
|
|
}).SetFieldStyle(tcell.StyleDefault.Blink(true))
|
|
|
|
formFields.AddInputField("Method Timeout", "3", 30, nil, func(text string) {
|
|
// Value will be read directly from form when needed
|
|
})
|
|
|
|
formFields.AddInputField("Reply Method", "webUI", 30, nil, func(text string) {
|
|
// Value will be read directly from form when needed
|
|
})
|
|
|
|
formFields.AddInputField("ACK Timeout", "0", 30, nil, func(text string) {
|
|
// Value will be read directly from form when needed
|
|
})
|
|
|
|
// Subscribe to responses
|
|
fromNode := "btdev1"
|
|
|
|
// Add buttons
|
|
formFields.AddButton("Send", func() {
|
|
// Get and save the current command to history
|
|
currentCmd := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField).GetText()
|
|
if currentCmd != "" {
|
|
// Add to history if not already present
|
|
found := false
|
|
for _, cmd := range historyCommands {
|
|
if cmd == currentCmd {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
historyCommands = append([]string{currentCmd}, historyCommands...)
|
|
historyList.Clear()
|
|
for _, cmd := range historyCommands {
|
|
historyList.AddItem(cmd, "", 0, nil).ShowSecondaryText(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get current values from form
|
|
methodTimeout, _ := strconv.Atoi(formFields.GetFormItemByLabel("Method Timeout").(*tview.InputField).GetText())
|
|
ackTimeout, _ := strconv.Atoi(formFields.GetFormItemByLabel("ACK Timeout").(*tview.InputField).GetText())
|
|
|
|
// Get and clean toNodes
|
|
toNodesText := formFields.GetFormItemByLabel("To Nodes").(*tview.InputField).GetText()
|
|
toNodes := strings.Split(toNodesText, ",")
|
|
// Trim spaces and filter out empty nodes
|
|
// cleanedToNodes := []string{}
|
|
// for _, node := range toNodes {
|
|
// if trimmed := strings.TrimSpace(node); trimmed != "" {
|
|
// cleanedToNodes = append(cleanedToNodes, trimmed)
|
|
// }
|
|
// }
|
|
|
|
cmd := CommandData{
|
|
// ToNodes: The values of toNodes will be used to fill toNode field when sending below.
|
|
JetstreamToNode: formFields.GetFormItemByLabel("Jetstream To Node").(*tview.InputField).GetText(),
|
|
UseDetectedShell: formFields.GetFormItemByLabel("Use Detected Shell").(*tview.Checkbox).IsChecked(),
|
|
Method: formFields.GetFormItemByLabel("Method").(*tview.InputField).GetText(),
|
|
MethodArgs: methodArgs,
|
|
MethodTimeout: methodTimeout,
|
|
FromNode: fromNode,
|
|
ReplyMethod: formFields.GetFormItemByLabel("Reply Method").(*tview.InputField).GetText(),
|
|
ACKTimeout: ackTimeout,
|
|
}
|
|
|
|
// Send command to each node
|
|
for _, node := range toNodes {
|
|
cmd.ToNode = node
|
|
|
|
// ---------------------------
|
|
|
|
var filePathToOpen string
|
|
foundFile := false
|
|
|
|
if strings.Contains(cmd.MethodArgs[2], "{{CTRL_FILE:") {
|
|
foundFile = true
|
|
|
|
// Example to split:
|
|
// echo {{CTRL_FILE:/somedir/msg_file.yaml}}>ctrlfile.txt
|
|
//
|
|
// Split at colon. We want the part after.
|
|
ss := strings.Split(cmd.MethodArgs[2], ":")
|
|
// Split at "}}",so pos [0] in the result contains just the file path.
|
|
sss := strings.Split(ss[1], "}}")
|
|
filePathToOpen = sss[0]
|
|
|
|
}
|
|
|
|
if foundFile {
|
|
|
|
fh, err := os.Open(filePathToOpen)
|
|
if err != nil {
|
|
fmt.Fprintf(outputView, "Error opening file: %v\n", err)
|
|
return
|
|
}
|
|
defer fh.Close()
|
|
|
|
b, err := io.ReadAll(fh)
|
|
if err != nil {
|
|
fmt.Fprintf(outputView, "Error reading file: %v\n", err)
|
|
return
|
|
}
|
|
|
|
// Replace the {{CTRL_FILE}} with the actual content read from file.
|
|
re := regexp.MustCompile(`(.*)({{CTRL_FILE.*}})(.*)`)
|
|
cmd.MethodArgs[2] = re.ReplaceAllString(cmd.MethodArgs[2], `${1}`+string(b)+`${3}`)
|
|
|
|
fmt.Fprintf(outputView, "DEBUG: Replaced {{CTRL_FILE}} with file content: %s\n", cmd.MethodArgs[2])
|
|
// ---
|
|
|
|
}
|
|
|
|
// ---------------------------
|
|
|
|
jsonData, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
fmt.Fprintf(outputView, "Error creating command: %v\n", err)
|
|
return
|
|
}
|
|
|
|
subject := fmt.Sprintf("%s.%s", strings.TrimSpace(node), cmd.Method)
|
|
|
|
if err := nc.Publish(subject, jsonData); err != nil {
|
|
fmt.Fprintf(outputView, "Error sending to %s: %v\n", node, err)
|
|
} else {
|
|
// fmt.Fprintf(outputView, "*** Command %s sent to %s, subject: %s\n", jsonData, node, subject)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Layout setup - adjust the proportion between form and output
|
|
mainFlex.AddItem(topFlex, 0, 7, true) // Form and history section
|
|
mainFlex.AddItem(outputView, 0, 5, false) // Output takes remaining space
|
|
|
|
return mainFlex
|
|
}
|
|
|
|
func createNodesPage() tview.Primitive {
|
|
// Create an empty page for now
|
|
return tview.NewBox().SetBorder(true).SetTitle("Nodes")
|
|
}
|
|
|
|
func updateFilesList() {
|
|
// Clear current list
|
|
// filesList.Clear()
|
|
|
|
// Create tui/files directory if it doesn't exist
|
|
filesDir := "files"
|
|
|
|
// Read all files from the directory
|
|
files, err := os.ReadDir(filesDir)
|
|
if err != nil {
|
|
fmt.Fprintf(outputView, "Error reading files directory: %v\n", err)
|
|
return
|
|
}
|
|
|
|
if filesList == nil {
|
|
fmt.Printf("FILES FILE FILE: %v\n", files)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Add each file to the list
|
|
for _, file := range files {
|
|
//fmt.Fprintf(outputView, "DEBUG: File: %s\n", file.Name())
|
|
//if !file.IsDir() {
|
|
filesList.AddItem(file.Name(), filepath.Join(filesDir, file.Name()), 0, nil)
|
|
//}
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
app = tview.NewApplication()
|
|
|
|
// Connect to NATS
|
|
var err error
|
|
nc, err = nats.Connect("nats://localhost:4222")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer nc.Close()
|
|
|
|
fromNode := "btdev1"
|
|
// --------------------------
|
|
// fmt.Fprintf(outputView, "DEBUG: BEFORE SUBSCRIBE\n")
|
|
sub, err := nc.Subscribe(fromNode+".webUI", func(msg *nats.Msg) {
|
|
var jsonMsg struct {
|
|
Data string `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(msg.Data, &jsonMsg); err != nil {
|
|
app.QueueUpdateDraw(func() {
|
|
fmt.Fprintf(outputView, "Error decoding message: %v\n", err)
|
|
})
|
|
return
|
|
}
|
|
|
|
// Decode base64 data
|
|
decoded, err := base64.StdEncoding.DecodeString(jsonMsg.Data)
|
|
if err != nil {
|
|
app.QueueUpdateDraw(func() {
|
|
fmt.Fprintf(outputView, "Error decoding base64: %v\n", err)
|
|
})
|
|
return
|
|
}
|
|
|
|
app.QueueUpdateDraw(func() {
|
|
fmt.Fprintf(outputView, "\nReceived message on %s:\n%s\n", msg.Subject, string(decoded))
|
|
})
|
|
})
|
|
// fmt.Fprintf(outputView, "DEBUG: AFTER SUBSCRIBE\n")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer func() {
|
|
sub.Unsubscribe()
|
|
}()
|
|
// --------------------------
|
|
|
|
// Create pages to manage multiple screens
|
|
pages := tview.NewPages()
|
|
|
|
// Create menu
|
|
menu := tview.NewList().
|
|
AddItem("Commands", "Send commands to nodes", 'c', func() {
|
|
pages.SwitchToPage("commands")
|
|
}).ShowSecondaryText(false).
|
|
AddItem("Nodes", "View and manage nodes", 'n', func() {
|
|
pages.SwitchToPage("nodes")
|
|
}).ShowSecondaryText(false).
|
|
AddItem("Quit", "Press to exit", 'q', func() {
|
|
app.Stop()
|
|
})
|
|
menu.SetBorder(true).SetTitle("Menu")
|
|
|
|
// Create layout with menu on left and pages on right
|
|
flex := tview.NewFlex().
|
|
AddItem(menu, 20, 1, true).
|
|
AddItem(pages, 0, 1, false)
|
|
|
|
// Add pages
|
|
pages.AddPage("commands", createCommandPage(), true, true)
|
|
pages.AddPage("nodes", createNodesPage(), true, false)
|
|
|
|
// Global keyboard shortcuts
|
|
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
switch event.Key() {
|
|
case tcell.KeyEsc:
|
|
// If on a page, go back to menu
|
|
if menu.HasFocus() {
|
|
app.Stop()
|
|
} else {
|
|
app.SetFocus(menu)
|
|
}
|
|
return nil
|
|
case tcell.KeyTab:
|
|
// Toggle between menu and current page
|
|
if menu.HasFocus() {
|
|
app.SetFocus(pages)
|
|
} else {
|
|
app.SetFocus(menu)
|
|
}
|
|
return nil
|
|
}
|
|
return event
|
|
})
|
|
|
|
// Update files list at startup
|
|
updateFilesList()
|
|
|
|
if err := app.SetRoot(flex, true).EnableMouse(true).Run(); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|