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 +}