diff --git a/.gitignore b/.gitignore
index 5b90e79..2130389 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,4 @@ go.work.sum
# env file
.env
-
+todo.txt
diff --git a/README.md b/README.md
index 4f88f4d..39564fd 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@ Install mctl using golang
```bash
go install code.jakeyoungdev.com/jake/mctl@main #it is recommended to use a tagged version
```
+
## Setup
### Configuring mctl
@@ -31,12 +32,48 @@ RCON@X.X.X.X /> list
There are 0 of a max of 20 players online:
```
+### Saving commands
+Commands can be saved under an alias for quick execution later, saved commands can contain placeholders '%s' that can be populated at runtime to allow for commands with unique runtime args to still be saved:
+```bash
+mctl save
+```
+
+### Viewing commands
+Saved commands can be viewed with:
+```bash
+mctl view
+```
+
+### Running saved commands
+Commands that have been saved can be run with:
+```bash
+mctl run
+```
+If the saved command contains placeholders, the necessary arguments must be supplied:
+```bash
+mctl run args...
+```
+
+### Saving and running example
+```bash
+#saving command named "test" to run "tp %s 0 0 0"
+mctl save test
+Command: tp %s 0 0 0
+
+#run command on user "jake"
+mctl run test jake
+#will run: tp jake 0 0 0 on remote server
+```
+
## Documentation
### Commands
|Command|Description|
|---|---|
|config|used to update the config file|
|login|makes connection request to the server using saved configuration and enters command loop|
+|save \|saves specific command for reuse|
+|view \|displays saved command|
+|run \ args...|runs saved command filling placeholders with supplied args|
### Flags
#### config
@@ -45,9 +82,6 @@ There are 0 of a max of 20 players online:
|port|p|yes|RCon port|
|server|s|yes|RCon address|
-#### login
-no flags
-
### Configuration file
All configuration data will be kept in /home/.mctl.yaml or C:\\Users\\username\\.mctl.yaml, passwords are encrypted for an added layer of security
diff --git a/client/mcr.go b/client/mcr.go
new file mode 100644
index 0000000..4ddd754
--- /dev/null
+++ b/client/mcr.go
@@ -0,0 +1,59 @@
+package client
+
+import (
+ "fmt"
+
+ "code.jakeyoungdev.com/jake/mctl/cryptography"
+ "github.com/jake-young-dev/mcr"
+ "github.com/spf13/viper"
+)
+
+/*
+ This is a simple wrapper for the MCR client to provide easy use of mcr without having to manually
+ decrypt the password/hit viper each time.
+*/
+
+type Client struct {
+ cli *mcr.Client
+}
+
+type IClient interface {
+ Close()
+ Command(cmd string) (string, error)
+}
+
+// creates a new mcr client using saved credentials and decrypted password
+func New() (*Client, error) {
+ //grab saved credentials
+ server := viper.Get("server").(string)
+ password := viper.Get("password").(string)
+ port := viper.Get("port").(int)
+ fmt.Printf("Logging into %s on port %d\n", server, port)
+
+ //decrypt password
+ pt, err := cryptography.DecryptPassword([]byte(password))
+ if err != nil {
+ return nil, err
+ }
+
+ //connect to game server
+ cli := mcr.NewClient(server, mcr.WithPort(port))
+ err = cli.Connect(string(pt))
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ cli: cli,
+ }, nil
+}
+
+// closes client connection
+func (c *Client) Close() error {
+ return c.cli.Close()
+}
+
+// sends command to server, only exists to prevent exposing cli field
+func (c *Client) Command(cmd string) (string, error) {
+ return c.cli.Command(cmd)
+}
diff --git a/cmd/config.go b/cmd/config.go
index 943c88d..7657368 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -4,27 +4,27 @@ Copyright © 2025 Jake jake.young.dev@gmail.com
package cmd
import (
- "crypto/aes"
- "crypto/cipher"
"crypto/rand"
"fmt"
"io"
"os"
+ "code.jakeyoungdev.com/jake/mctl/cryptography"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"
)
var (
- server string
- port int
+ cfgserver string
+ cfgport int
)
// configCmd represents the config command
var configCmd = &cobra.Command{
- Use: "config",
- Short: "Create and populate config file",
+ Use: "config",
+ Example: "mctl config -s x.x.x.x -p 61695",
+ Short: "Create and populate config file",
Long: `Creates the .mctl file in the user home directory
populating it with the server address, rcon password, and
rcon port to be pulled when using Login command`,
@@ -34,24 +34,20 @@ var configCmd = &cobra.Command{
ps, err := term.ReadPassword(int(os.Stdin.Fd()))
cobra.CheckErr(err)
- //setup aes encrypter
- block, err := aes.NewCipher([]byte(viper.Get("device").(string)))
- cobra.CheckErr(err)
//generate and apply random nonce
nonce := make([]byte, 12)
_, err = io.ReadFull(rand.Reader, nonce)
cobra.CheckErr(err)
- aesg, err := cipher.NewGCM(block)
+ viper.Set("nonce", string(nonce))
+
+ //encrypt password
+ ciphert, err := cryptography.EncryptPassword(ps)
cobra.CheckErr(err)
- //encrypt rcon password
- ciphert := aesg.Seal(nil, nonce, ps, nil)
-
//update config file with new values
- viper.Set("server", server)
+ viper.Set("server", cfgserver)
viper.Set("password", string(ciphert))
- viper.Set("port", port)
- viper.Set("nonce", string(nonce))
+ viper.Set("port", cfgport)
viper.WriteConfig()
fmt.Println()
fmt.Println("Config file updated!")
@@ -60,13 +56,14 @@ var configCmd = &cobra.Command{
func init() {
initConfig()
- configCmd.Flags().StringVarP(&server, "server", "s", "", "server address")
+ configCmd.Flags().StringVarP(&cfgserver, "server", "s", "", "server address")
configCmd.MarkFlagRequired("server")
- configCmd.Flags().IntVarP(&port, "port", "p", 0, "server rcon port")
+ configCmd.Flags().IntVarP(&cfgport, "port", "p", 0, "server rcon port")
configCmd.MarkFlagRequired("port")
rootCmd.AddCommand(configCmd)
}
+// init config sets viper config and checks for config file, creating it if it doesn't exist
func initConfig() {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
@@ -74,15 +71,14 @@ func initConfig() {
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".mctl")
-
viper.AutomaticEnv()
viper.ReadInConfig()
if err := viper.ReadInConfig(); err != nil {
//file does not exist, create it
- viper.Set("server", server)
+ viper.Set("server", cfgserver)
viper.Set("password", "")
- viper.Set("port", port)
+ viper.Set("port", cfgport)
viper.Set("nonce", "")
//generate psuedo-random key
diff --git a/cmd/login.go b/cmd/login.go
index c70abfc..a0a5e97 100644
--- a/cmd/login.go
+++ b/cmd/login.go
@@ -5,45 +5,25 @@ package cmd
import (
"bufio"
- "crypto/aes"
- "crypto/cipher"
"fmt"
"os"
- "github.com/jake-young-dev/mcr"
+ "code.jakeyoungdev.com/jake/mctl/client"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// loginCmd represents the login command
var loginCmd = &cobra.Command{
- Use: "login",
- Short: "Login to server and send commands",
+ Use: "login",
+ Example: "mctl login",
+ Short: "Login to server and send commands",
Long: `Login to server using saved config and enter command loop
sending commands to server and printing the response.`,
Run: func(cmd *cobra.Command, args []string) {
//grab saved credentials
- server := viper.Get("server")
- password := viper.Get("password")
- port := viper.Get("port")
- fmt.Printf("Logging into %s on port %d\n", server, port)
-
- //setup decrypter
- nonce := viper.Get("nonce")
- block, err := aes.NewCipher([]byte(viper.Get("device").(string)))
- cobra.CheckErr(err)
- aesg, err := cipher.NewGCM(block)
- cobra.CheckErr(err)
-
- //decrypt password
- pwd := []byte(password.(string))
- nn := []byte(nonce.(string))
- pt, err := aesg.Open(nil, nn, pwd, nil)
- cobra.CheckErr(err)
-
- //connect to game server
- cli := mcr.NewClient(server.(string), mcr.WithPort(port.(int)))
- err = cli.Connect(string(pt))
+ server := viper.Get("server").(string)
+ cli, err := client.New()
cobra.CheckErr(err)
defer cli.Close()
@@ -61,6 +41,7 @@ var loginCmd = &cobra.Command{
continue
}
+ //mctl exits command terminal
if runningCmd == "mctl" {
break
}
diff --git a/cmd/root.go b/cmd/root.go
index 4e1173c..fa5d4f3 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -14,7 +14,7 @@ var rootCmd = &cobra.Command{
Use: "mctl",
Short: "A remote console client",
Long: `mctl is a terminal-friendly remote console client made to manage game servers.`,
- Version: "v0.1.1",
+ Version: "v0.2.0",
// Run: func(cmd *cobra.Command, args []string) { },
}
diff --git a/cmd/run.go b/cmd/run.go
new file mode 100644
index 0000000..5c84158
--- /dev/null
+++ b/cmd/run.go
@@ -0,0 +1,64 @@
+/*
+Copyright © 2025 Jake jake.young.dev@gmail.com
+*/
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "code.jakeyoungdev.com/jake/mctl/client"
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+)
+
+// runCmd represents the run command
+var runCmd = &cobra.Command{
+ Use: "run args...",
+ Example: "mctl run savedcmd 63 jake",
+ Short: "Runs a previously saved command with supplied arguments on remote server",
+ Long: `Loads a saved command, injects the supplied arguments into the command, and sends the command to the remove server
+ printing the response`,
+ Run: func(cmd *cobra.Command, args []string) {
+ //grab saved command
+ cmdName := viper.Get(fmt.Sprintf("customCmd-%s", args[0])).(string)
+ //convert arguments to interface
+ var nargs []any
+ for _, a := range args[1:] {
+ nargs = append(nargs, a)
+ }
+
+ //inject arguments
+ fixed := fmt.Sprintf(cmdName, nargs...)
+ fmt.Printf("Running saved command %s\n", fixed)
+
+ //create client and send command
+ cli, err := client.New()
+ cobra.CheckErr(err)
+ defer cli.Close()
+
+ res, err := cli.Command(fixed)
+ cobra.CheckErr(err)
+
+ fmt.Println(res)
+ },
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ //ensure we have a command name
+ al := len(args)
+ if al == 0 {
+ return errors.New("name argument is required")
+ }
+ cmdCheck := viper.Get(fmt.Sprintf("customCmd-%s", args[0]))
+ count := strings.Count(cmdCheck.(string), "%s")
+ //make sure enough arguments are sent to fill command placeholders
+ if al < count+1 {
+ return fmt.Errorf("not enough arguments to populate command. Supplied: %d, Needed: %d", al-1, count)
+ }
+ return nil
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(runCmd)
+}
diff --git a/cmd/save.go b/cmd/save.go
new file mode 100644
index 0000000..24fff42
--- /dev/null
+++ b/cmd/save.go
@@ -0,0 +1,48 @@
+/*
+Copyright © 2025 Jake jake.young.dev@gmail.com
+*/
+package cmd
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+)
+
+// saveCmd represents the save command
+var saveCmd = &cobra.Command{
+ Use: "save ",
+ Example: "mctl save newcmd",
+ Short: "Saves a server command under an alias for quick execution",
+ Long: `Saves supplied command using alias to allow the command to be executed using the run command. The %s placeholder can be
+ used as a wildcard to be injected when running the command`,
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Printf("Use %s as a wildcard in your command\n", "%s") //this is ugly, have to use printf to stop issues with compiler
+ fmt.Printf("Command: ")
+ sc := bufio.NewScanner(os.Stdin)
+ if sc.Scan() {
+ txt := sc.Text()
+ if txt != "" {
+ viper.Set(fmt.Sprintf("customCmd-%s", args[0]), txt)
+ viper.WriteConfig()
+ fmt.Println("\nSaved!")
+ return
+ }
+ }
+ },
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return errors.New("name argument is required")
+ }
+
+ return nil
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(saveCmd)
+}
diff --git a/cmd/view.go b/cmd/view.go
new file mode 100644
index 0000000..260f8d4
--- /dev/null
+++ b/cmd/view.go
@@ -0,0 +1,34 @@
+/*
+Copyright © 2025 Jake jake.young.dev@gmail.com
+*/
+package cmd
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+)
+
+// viewCmd represents the view command
+var viewCmd = &cobra.Command{
+ Use: "view ",
+ Example: "mctl view test",
+ Short: "View saved commands",
+ Long: `Load command using the supplied name and displays it in the terminal`,
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Printf("\nCommand: %s\n", viper.Get(args[0]))
+ },
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return errors.New("name argument is required")
+ }
+
+ return nil
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(viewCmd)
+}
diff --git a/cryptography/aes.go b/cryptography/aes.go
new file mode 100644
index 0000000..8458bfc
--- /dev/null
+++ b/cryptography/aes.go
@@ -0,0 +1,49 @@
+package cryptography
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+
+ "github.com/spf13/viper"
+)
+
+func EncryptPassword(b []byte) ([]byte, error) {
+ nonce := viper.Get("nonce").(string)
+ dev := viper.Get("device").(string)
+
+ block, err := aes.NewCipher([]byte(dev))
+ if err != nil {
+ return nil, err
+ }
+
+ aesg, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+
+ ct := aesg.Seal(nil, []byte(nonce), []byte(b), nil)
+ return ct, nil
+}
+
+func DecryptPassword(b []byte) (string, error) {
+ nonce := viper.Get("nonce").(string)
+ password := viper.Get("password").(string)
+ dev := viper.Get("device").(string)
+
+ block, err := aes.NewCipher([]byte(dev))
+ if err != nil {
+ return "", err
+ }
+
+ aesg, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+
+ op, err := aesg.Open(nil, []byte(nonce), []byte(password), nil)
+ if err != nil {
+ return "", err
+ }
+
+ return string(op), nil
+}