Compare commits

...

4 Commits

Author SHA1 Message Date
8daa621528 delete added, more updates see below
- readme updates
- delete command added
- commands now saved in a map vs fields
- login and run now ensure config has been run to prevent errors
2025-04-17 17:09:16 -04:00
7f090dbce7 update readme and version bump 2025-04-17 11:37:37 -04:00
f943639a88 lots o updates
- moved mcr code to package
- added the save, run, and view commands
- commands can now be saved
- saved commands support placeholders
2025-04-17 11:20:39 -04:00
bef3521770 subcommands and structure
- added run, save, and view subcommands
- WIP still
- moved encryption and decryption to util file
- a good start
2025-04-16 19:25:21 -04:00
11 changed files with 424 additions and 55 deletions

2
.gitignore vendored
View File

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

View File

@ -6,10 +6,11 @@ Install mctl using golang
```bash
go install code.jakeyoungdev.com/jake/mctl@main #it is recommended to use a tagged version
```
<br />
## Setup
### Configuring mctl
mctl requires a one-time setup via the 'config' command, password is entered securely from the terminal
mctl requires a one-time setup via the 'config' command before interacting with any servers, password is entered securely from the terminal
```bash
mctl config -s <serveraddress> -p <rconport>
```
@ -31,12 +32,55 @@ 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 <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
```
### Delete saved command
Commands can be deleted with:
```bash
mctl delete <name>
```
## 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 \<name>|saves specific command for reuse|
|view \<name>|displays saved command|
|delete \<name>|deletes saved command|
|run \<name> args...|runs saved command filling placeholders with supplied args|
### Flags
#### config
@ -45,9 +89,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

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
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
@ -90,6 +86,11 @@ func initConfig() {
_, err := rand.Read(uu)
cobra.CheckErr(err)
//create custom command map
cmdMap := make(map[string]any, 0)
//write config
viper.Set("customcmd", cmdMap)
viper.Set("device", string(uu))
viper.SafeWriteConfig()
}

42
cmd/delete.go Normal file
View File

@ -0,0 +1,42 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// deleteCmd represents the delete command
var deleteCmd = &cobra.Command{
Use: "delete <name>",
Example: "mctl delete newcmd",
Short: "Delete a saved command",
Long: `Deletes a command stored using the save command`,
Run: func(cmd *cobra.Command, args []string) {
var cm map[string]any
cmdMap := viper.Get("customcmd")
if cmdMap == nil {
cm = make(map[string]any, 0)
} else {
cm = cmdMap.(map[string]any)
}
delete(cm, args[0])
viper.Set("customcmd", cmdMap)
viper.WriteConfig()
},
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(deleteCmd)
}

View File

@ -5,45 +5,27 @@ package cmd
import (
"bufio"
"crypto/aes"
"crypto/cipher"
"errors"
"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",
SilenceUsage: true,
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 +43,7 @@ var loginCmd = &cobra.Command{
continue
}
//mctl exits command terminal
if runningCmd == "mctl" {
break
}
@ -73,6 +56,13 @@ var loginCmd = &cobra.Command{
fmt.Printf("Disconnected from %s\n", server)
},
PreRunE: func(cmd *cobra.Command, args []string) error {
//ensure config command has been run
if viper.Get("server") == "" || viper.Get("password") == "" || viper.Get("port") == 0 {
return errors.New("the 'config' command must be run before you can interact with servers")
}
return nil
},
}
func init() {

View File

@ -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) { },
}
@ -28,6 +28,5 @@ func Execute() {
}
func init() {
//dont show completion subcommand in help message, makes the syntax confusing
rootCmd.Root().CompletionOptions.DisableDefaultCmd = true
}

85
cmd/run.go Normal file
View File

@ -0,0 +1,85 @@
/*
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",
SilenceUsage: true,
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) {
//check for command map
var cm map[string]any
cmdMap := viper.Get("customcmd")
if cmdMap == nil {
cm = make(map[string]any, 0)
} else {
cm = cmdMap.(map[string]any)
}
//is this an existing command
cmdRun, ok := cm[args[0]]
if !ok {
fmt.Printf("command %s not found", args[0])
return
}
//convert arguments to interface
var nargs []any
for _, a := range args[1:] {
nargs = append(nargs, a)
}
//inject arguments
fixed := fmt.Sprintf(cmdRun.(string), 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 configuration has been setup
if viper.Get("server") == "" || viper.Get("password") == "" || viper.Get("port") == 0 {
return errors.New("the 'config' command must be run before you can interact with servers")
}
//ensure we have a command name
al := len(args)
if al == 0 {
return errors.New("name argument is required")
}
cmdMap := viper.Get("customcmd").(map[string]any)
count := strings.Count(cmdMap[args[0]].(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)
}

55
cmd/save.go Normal file
View File

@ -0,0 +1,55 @@
/*
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 != "" {
var cmdMap map[string]any
cm := viper.Get("customcmd")
if cmdMap == nil {
cmdMap = make(map[string]any, 0)
} else {
cmdMap = cm.(map[string]any)
}
cmdMap[args[0]] = txt
viper.Set("customcmd", cmdMap)
viper.WriteConfig()
fmt.Println("\nSaved!")
}
}
},
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)
}

48
cmd/view.go Normal file
View File

@ -0,0 +1,48 @@
/*
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) {
var cm map[string]any
cmdMap := viper.Get("customcmd")
if cmdMap == nil {
fmt.Println("no custom commands found")
return
}
cm = cmdMap.(map[string]any)
custom, ok := cm[args[0]]
if !ok {
fmt.Println("command not found")
return
}
fmt.Printf("Command: %s", custom.(string))
},
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
}