dev/command-saving #1

Merged
jake merged 3 commits from dev/command-saving into main 2025-04-17 15:40:55 +00:00
10 changed files with 317 additions and 52 deletions

2
.gitignore vendored
View File

@ -24,4 +24,4 @@ go.work.sum
# env file # env file
.env .env
todo.txt

View File

@ -6,6 +6,7 @@ Install mctl using golang
```bash ```bash
go install code.jakeyoungdev.com/jake/mctl@main #it is recommended to use a tagged version go install code.jakeyoungdev.com/jake/mctl@main #it is recommended to use a tagged version
``` ```
<br />
## Setup ## Setup
### Configuring mctl ### Configuring mctl
@ -31,12 +32,48 @@ RCON@X.X.X.X /> list
There are 0 of a max of 20 players online: 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 <name>
```
### Viewing commands
Saved commands can be viewed with:
```bash
mctl view <name>
```
### Running saved commands
Commands that have been saved can be run with:
```bash
mctl run <name>
```
If the saved command contains placeholders, the necessary arguments must be supplied:
```bash
mctl run <name> 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 ## Documentation
### Commands ### Commands
|Command|Description| |Command|Description|
|---|---| |---|---|
|config|used to update the config file| |config|used to update the config file|
|login|makes connection request to the server using saved configuration and enters command loop| |login|makes connection request to the server using saved configuration and enters command loop|
|save \<name>|saves specific command for reuse|
|view \<name>|displays saved command|
|run \<name> args...|runs saved command filling placeholders with supplied args|
### Flags ### Flags
#### config #### config
@ -45,9 +82,6 @@ There are 0 of a max of 20 players online:
|port|p|yes|RCon port| |port|p|yes|RCon port|
|server|s|yes|RCon address| |server|s|yes|RCon address|
#### login
no flags
### Configuration file ### 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 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

59
client/mcr.go Normal file
View File

@ -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)
}

View File

@ -4,27 +4,27 @@ Copyright © 2025 Jake jake.young.dev@gmail.com
package cmd package cmd
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/rand" "crypto/rand"
"fmt" "fmt"
"io" "io"
"os" "os"
"code.jakeyoungdev.com/jake/mctl/cryptography"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.org/x/term" "golang.org/x/term"
) )
var ( var (
server string cfgserver string
port int cfgport int
) )
// configCmd represents the config command // configCmd represents the config command
var configCmd = &cobra.Command{ var configCmd = &cobra.Command{
Use: "config", Use: "config",
Short: "Create and populate config file", 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 Long: `Creates the .mctl file in the user home directory
populating it with the server address, rcon password, and populating it with the server address, rcon password, and
rcon port to be pulled when using Login command`, 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())) ps, err := term.ReadPassword(int(os.Stdin.Fd()))
cobra.CheckErr(err) cobra.CheckErr(err)
//setup aes encrypter
block, err := aes.NewCipher([]byte(viper.Get("device").(string)))
cobra.CheckErr(err)
//generate and apply random nonce //generate and apply random nonce
nonce := make([]byte, 12) nonce := make([]byte, 12)
_, err = io.ReadFull(rand.Reader, nonce) _, err = io.ReadFull(rand.Reader, nonce)
cobra.CheckErr(err) cobra.CheckErr(err)
aesg, err := cipher.NewGCM(block) viper.Set("nonce", string(nonce))
//encrypt password
ciphert, err := cryptography.EncryptPassword(ps)
cobra.CheckErr(err) cobra.CheckErr(err)
//encrypt rcon password
ciphert := aesg.Seal(nil, nonce, ps, nil)
//update config file with new values //update config file with new values
viper.Set("server", server) viper.Set("server", cfgserver)
viper.Set("password", string(ciphert)) viper.Set("password", string(ciphert))
viper.Set("port", port) viper.Set("port", cfgport)
viper.Set("nonce", string(nonce))
viper.WriteConfig() viper.WriteConfig()
fmt.Println() fmt.Println()
fmt.Println("Config file updated!") fmt.Println("Config file updated!")
@ -60,13 +56,14 @@ var configCmd = &cobra.Command{
func init() { func init() {
initConfig() initConfig()
configCmd.Flags().StringVarP(&server, "server", "s", "", "server address") configCmd.Flags().StringVarP(&cfgserver, "server", "s", "", "server address")
configCmd.MarkFlagRequired("server") 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") configCmd.MarkFlagRequired("port")
rootCmd.AddCommand(configCmd) rootCmd.AddCommand(configCmd)
} }
// init config sets viper config and checks for config file, creating it if it doesn't exist
func initConfig() { func initConfig() {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
cobra.CheckErr(err) cobra.CheckErr(err)
@ -74,15 +71,14 @@ func initConfig() {
viper.AddConfigPath(home) viper.AddConfigPath(home)
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.SetConfigName(".mctl") viper.SetConfigName(".mctl")
viper.AutomaticEnv() viper.AutomaticEnv()
viper.ReadInConfig() viper.ReadInConfig()
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
//file does not exist, create it //file does not exist, create it
viper.Set("server", server) viper.Set("server", cfgserver)
viper.Set("password", "") viper.Set("password", "")
viper.Set("port", port) viper.Set("port", cfgport)
viper.Set("nonce", "") viper.Set("nonce", "")
//generate psuedo-random key //generate psuedo-random key

View File

@ -5,45 +5,25 @@ package cmd
import ( import (
"bufio" "bufio"
"crypto/aes"
"crypto/cipher"
"fmt" "fmt"
"os" "os"
"github.com/jake-young-dev/mcr" "code.jakeyoungdev.com/jake/mctl/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// loginCmd represents the login command // loginCmd represents the login command
var loginCmd = &cobra.Command{ var loginCmd = &cobra.Command{
Use: "login", Use: "login",
Short: "Login to server and send commands", Example: "mctl login",
Short: "Login to server and send commands",
Long: `Login to server using saved config and enter command loop Long: `Login to server using saved config and enter command loop
sending commands to server and printing the response.`, sending commands to server and printing the response.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
//grab saved credentials //grab saved credentials
server := viper.Get("server") server := viper.Get("server").(string)
password := viper.Get("password") cli, err := client.New()
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))
cobra.CheckErr(err) cobra.CheckErr(err)
defer cli.Close() defer cli.Close()
@ -61,6 +41,7 @@ var loginCmd = &cobra.Command{
continue continue
} }
//mctl exits command terminal
if runningCmd == "mctl" { if runningCmd == "mctl" {
break break
} }

View File

@ -14,7 +14,7 @@ var rootCmd = &cobra.Command{
Use: "mctl", Use: "mctl",
Short: "A remote console client", Short: "A remote console client",
Long: `mctl is a terminal-friendly remote console client made to manage game servers.`, 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) { }, // Run: func(cmd *cobra.Command, args []string) { },
} }

64
cmd/run.go Normal file
View File

@ -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 <name> 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)
}

48
cmd/save.go Normal file
View File

@ -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 <name>",
Example: "mctl save newcmd",
Short: "Saves a server command under an alias for quick execution",
Long: `Saves supplied command using alias <name> 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)
}

34
cmd/view.go Normal file
View File

@ -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 <name>",
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)
}

49
cryptography/aes.go Normal file
View File

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